feat(access-keys): 支持自定义密钥并统一编辑与生成交互 (#611)

* feat(access-keys): 支持自定义密钥与强度提示

* fix(access-keys): 恢复默认掩码并按密钥长度分档

* feat(access-keys): 统一密钥编辑与随机生成交互

* style(access-keys): 改用骰子图标生成密钥

* fix(access-keys): 修复筛选幂等判定与恢复状态提示

* fix(access-keys): 统一编辑请求的幂等摘要
This commit is contained in:
tbphp authored and GitHub committed 2026-09-09 23:05:09 +08:00
1 parent d417b7de04
commit de6d2d44fc
55 files changed
+2065 -633

No files matched your search

+3 -1
View File
@@ -53,6 +53,7 @@ type accessKeyCollectionRecord struct {
}
type accessKeyCollectionRow struct {
KeyPrefix string
PriceMultiplierMicros *int64
ID uint
Name string
@@ -122,7 +123,7 @@ func (s *Service) captureAccessKeyCollectionRecords(
if err := s.withReadSnapshot(ctx, func(tx *gorm.DB) error {
if err := tx.Model(&models.AccessKey{}).
Select(
"access_keys.id", "access_keys.name", "access_keys.key_suffix",
"access_keys.id", "access_keys.name", "access_keys.key_prefix", "access_keys.key_suffix",
"access_keys.status", "access_keys.filters", "access_keys.rpm_limit",
"access_keys.expires_at_ms", "access_keys.price_multiplier_micros",
"access_keys.created_at_ms", "access_keys.updated_at_ms",
@@ -161,6 +162,7 @@ func (s *Service) captureAccessKeyCollectionRecords(
ID: row.ID,
Name: row.Name,
KeySuffix: row.KeySuffix,
KeyPrefix: row.KeyPrefix,
Status: row.Status,
Filters: row.Filters,
RPMLimit: row.RPMLimit,
@@ -182,7 +182,7 @@ func TestListAccessKeyCollectionRejectsCanceledContextAndInvalidMappedMetadata(t
}
row := models.AccessKey{
Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "ZZZZ",
Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "bad ",
Status: string(state.AccessKeyStatusActive), Filters: models.JSON(`{}`),
}
if err := fixture.db.Exec("PRAGMA ignore_check_constraints = ON").Error; err != nil {
@@ -202,7 +202,7 @@ func TestListAccessKeyCollectionRejectsCanceledContextAndInvalidMappedMetadata(t
func accessKeyCollectionQueryRecord(id uint, name, suffix string, status state.AccessKeyStatus, updatedAtMS int64) accessKeyCollectionRecord {
return accessKeyCollectionRecord{AccessKeyCollectionItem: AccessKeyCollectionItem{
AccessKeyMetadata: AccessKeyMetadata{
ID: id, Name: name, MaskedKey: maskedAccessKey(suffix), Status: status, UpdatedAtMS: updatedAtMS,
ID: id, Name: name, MaskedKey: maskedAccessKey("sk-gl-", suffix), Status: status, UpdatedAtMS: updatedAtMS,
},
}}
}
+168
View File
@@ -0,0 +1,168 @@
package control
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gpt-load/internal/storage/models"
)
func TestCustomAccessKeyCreationAndReplay(t *testing.T) {
t.Parallel()
initControlI18n(t)
for _, key := range []string{"x", "123456", "imported-key_Z9-", "symbols-!\"\\'~", strings.Repeat("A", 256)} {
t.Run(fmt.Sprintf("length-%d", len(key)), func(t *testing.T) {
fixture := newServiceFixture(t)
engine := newAccessKeyLifecycleEngine(t, fixture)
body, err := json.Marshal(map[string]string{"name": "custom", "key": key})
if err != nil {
t.Fatal(err)
}
const operation = "00000000-0000-4000-8000-000000008101"
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation)
if first.Code != http.StatusOK {
t.Fatalf("custom key creation status = %d", first.Code)
}
var response struct {
Data AccessKeyCreateResult `json:"data"`
}
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
created := response.Data
if created.Key != key || created.ID == 0 {
t.Fatal("creation did not preserve the custom key")
}
if len(key) <= 8 && created.MaskedKey != "********" {
t.Fatal("short key was not fully masked")
}
if len(key) > 8 && len(key) <= 16 && created.MaskedKey != "****"+key[len(key)-4:] {
t.Fatal("custom key suffix was not preserved")
}
if len(key) > 16 && created.MaskedKey != key[:6]+"****"+key[len(key)-4:] {
t.Fatal("custom key prefix and suffix were not preserved")
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if row.KeyValue == key || row.KeyHash != fixture.encryption.Hash(key) {
t.Fatal("custom key storage is not encrypted and hashed")
}
revealed, err := fixture.service.RevealAccessKey(t.Context(), created.ID)
if err != nil || revealed.Key != key {
t.Fatal("custom key reveal failed")
}
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; !ok {
t.Fatal("custom key not published")
}
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
request.Header.Set("Authorization", "Bearer "+key)
login := httptest.NewRecorder()
engine.ServeHTTP(login, request)
if login.Code != http.StatusOK {
t.Fatalf("custom key login status = %d", login.Code)
}
replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation)
if replay.Code != http.StatusOK {
t.Fatalf("replay status = %d", replay.Code)
}
data := decodeAccessKeyLifecycleData(t, replay)
if _, exists := data["key"]; exists {
t.Fatal("replay exposed a secret")
}
assertJSONRawEqual(t, data["replayed"], "true")
otherBody, err := json.Marshal(map[string]string{"name": "custom", "key": "different-custom-key"})
if err != nil {
t.Fatal(err)
}
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(otherBody), operation)
if conflict.Code != http.StatusConflict {
t.Fatalf("changed key replay status = %d", conflict.Code)
}
duplicate := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), "00000000-0000-4000-8000-000000008102")
if duplicate.Code != http.StatusConflict {
t.Fatalf("duplicate key status = %d", duplicate.Code)
}
var count int64
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 1 {
t.Fatal("replay or duplicate created another key")
}
var operations []models.ControlOperation
if err := fixture.db.Find(&operations).Error; err != nil {
t.Fatal(err)
}
for _, operation := range operations {
var metadata map[string]json.RawMessage
if len(operation.CanonicalResult) > 0 {
if err := json.Unmarshal(operation.CanonicalResult, &metadata); err != nil {
t.Fatal(err)
}
if _, exists := metadata["key"]; exists {
t.Fatal("operation metadata persisted the secret")
}
}
}
})
}
}
func TestCustomAccessKeyEmptyUsesAutomaticGeneration(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := newAccessKeyLifecycleEngine(t, fixture)
const operation = "00000000-0000-4000-8000-000000008201"
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic","key":""}`, operation)
if response.Code != http.StatusOK {
t.Fatalf("empty key creation status = %d", response.Code)
}
data := decodeAccessKeyLifecycleData(t, response)
var key string
if err := json.Unmarshal(data["key"], &key); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(key, "sk-gl-") || len(key) != 38 {
t.Fatal("empty input did not generate a random key")
}
replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic"}`, operation)
if replay.Code != http.StatusOK {
t.Fatalf("omitted key replay status = %d", replay.Code)
}
assertJSONRawEqual(t, decodeAccessKeyLifecycleData(t, replay)["replayed"], "true")
}
func TestCustomAccessKeyRejectsUnusableValues(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := newAccessKeyLifecycleEngine(t, fixture)
for index, key := range []string{" ", "leading ", "two words", "line\nbreak", "tab\tkey", "nul\x00key", "中文", strings.Repeat("x", 257), authTestKey} {
body, err := json.Marshal(map[string]string{"name": "invalid", "key": key})
if err != nil {
t.Fatal(err)
}
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), fmt.Sprintf("00000000-0000-4000-8000-%012d", index+8200))
if response.Code != http.StatusBadRequest {
t.Fatalf("case %d status = %d", index, response.Code)
}
var envelope struct {
Code string `json:"code"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
wantCode := "INVALID_CUSTOM_ACCESS_KEY"
if key == authTestKey {
wantCode = "ACCESS_KEY_ADMIN_CONFLICT"
}
if envelope.Code != wantCode {
t.Fatalf("case %d error code = %s", index, envelope.Code)
}
}
var count int64
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 0 {
t.Fatal("invalid input created a key")
}
}
@@ -0,0 +1,318 @@
package control
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"testing"
"gpt-load/internal/channel"
"gpt-load/internal/state"
"gpt-load/internal/storage/models"
)
func TestEditAccessKeyReplaysNormalizedFields(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "before",
CostLimitRules: OptionalAccessKeyCostLimitRules{Set: true, Values: []AccessKeyCostLimitRuleRequest{
{Kind: "total", LimitUSD: "10"},
{Kind: "periodic", LimitUSD: "2", PeriodSeconds: 3600},
}},
})
if err != nil {
t.Fatal(err)
}
totalID, periodicID := created.CostLimitRules[0].ID, created.CostLimitRules[1].ID
totalRule := fmt.Sprintf(`{"id":%d,"kind":"total","limit_usd":"10"}`, totalID)
periodicRule := fmt.Sprintf(`{"id":%d,"kind":"periodic","limit_usd":"2","period_seconds":3600}`, periodicID)
engine := newAccessKeyLifecycleEngine(t, fixture)
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
for index, test := range []struct {
name, initial, retry, different string
}{
{"name", `"name":" client "`, `"name":"client"`, `"name":"other"`},
{"default multiplier", `"price_multiplier":"1.0"`, `"price_multiplier":"1"`, `"price_multiplier":"2"`},
{"fractional multiplier", `"price_multiplier":"1.50"`, `"price_multiplier":"1.5"`, `"price_multiplier":"1.6"`},
{
"cost amount",
fmt.Sprintf(`"cost_limit_rules":[{"id":%d,"kind":"total","limit_usd":"10.00"},%s]`, totalID, periodicRule),
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, totalRule, periodicRule),
fmt.Sprintf(`"cost_limit_rules":[{"id":%d,"kind":"total","limit_usd":"11"},%s]`, totalID, periodicRule),
},
{
"cost rule order and identity",
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, totalRule, periodicRule),
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, periodicRule, totalRule),
fmt.Sprintf(`"cost_limit_rules":[{"kind":"total","limit_usd":"10"},%s]`, periodicRule),
},
{"empty cost rules", `"cost_limit_rules":[]`, `"cost_limit_rules":[]`, `"cost_limit_rules":[{"kind":"total","limit_usd":"10"}]`},
} {
t.Run(test.name, func(t *testing.T) {
operationID := fmt.Sprintf("00000000-0000-4000-8000-%012d", 8621+index)
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path,
`{"key":"replacement-value",`+test.initial+`}`, operationID)
if first.Code != http.StatusOK {
t.Fatalf("initial edit status = %d", first.Code)
}
before := loadAccessKeyRow(t, fixture.db, created.ID)
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path,
`{"key":"replacement-value",`+test.retry+`}`, operationID)
if replayed.Code != http.StatusOK {
t.Fatalf("equivalent edit replay status = %d", replayed.Code)
}
if !reflect.DeepEqual(decodeAccessKeyLifecycleData(t, first), decodeAccessKeyLifecycleData(t, replayed)) {
t.Fatal("replay did not return the original result")
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue {
t.Fatal("replay rewrote the credential")
}
for _, body := range []string{
`{"key":"replacement-value"}`,
`{"key":"replacement-value",` + test.different + `}`,
} {
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
if conflict.Code != http.StatusConflict {
t.Fatalf("omitted or different mutation status = %d", conflict.Code)
}
}
})
}
}
func TestEditAccessKeyReplaysEquivalentFilters(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupIDs := make([]uint, 0, 2)
for index := range 2 {
group, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
ChannelID: channel.OpenAICompatible, ConnectionType: models.ConnectionTypeAPIKey,
Params: json.RawMessage(fmt.Sprintf(`{"base_url":"https://group-%d.example.com/v1"}`, index)),
Models: optionalGroupModels{Set: true, Values: []GroupModel{}}, Credentials: "test-credential",
})
if err != nil {
t.Fatal(err)
}
groupIDs = append(groupIDs, group.GroupID)
}
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "filter-replay"})
if err != nil {
t.Fatal(err)
}
engine := newAccessKeyLifecycleEngine(t, fixture)
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
for index, test := range []struct {
name string
initial string
retry string
}{
{
name: "normalized sets",
initial: fmt.Sprintf(`{"key":"replacement-value","filters":{
"groups":[%d,%d,%d],"protocols":["anthropic","openai-completions","anthropic"],
"models":[" gpt-b ","gpt-a","gpt-a"],
"allowed_cidrs":["198.51.100.7/24","192.0.2.1","192.0.2.1/32"]
}}`, groupIDs[1], groupIDs[0], groupIDs[1]),
retry: fmt.Sprintf(`{"key":"replacement-value","filters":{
"groups":[%d,%d],"protocols":["openai-completions","anthropic"],
"models":["gpt-a","gpt-b"],"allowed_cidrs":["192.0.2.1/32","198.51.100.0/24"]
}}`, groupIDs[0], groupIDs[1]),
},
{
name: "empty sets",
initial: `{"key":"replacement-value","filters":{}}`,
retry: `{"key":"replacement-value","filters":{"groups":[],"protocols":[],"models":[],"allowed_cidrs":[]}}`,
},
} {
t.Run(test.name, func(t *testing.T) {
operationID := fmt.Sprintf("00000000-0000-4000-8000-%012d", 8430+index)
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, test.initial, operationID)
if first.Code != http.StatusOK {
t.Fatalf("initial edit status = %d", first.Code)
}
before := loadAccessKeyRow(t, fixture.db, created.ID)
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, test.retry, operationID)
if replayed.Code != http.StatusOK {
t.Fatalf("equivalent filter replay status = %d", replayed.Code)
}
if !bytes.Equal(decodeAccessKeyLifecycleData(t, first)["filters"], decodeAccessKeyLifecycleData(t, replayed)["filters"]) {
t.Fatal("replay did not return the original filter result")
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue || !bytes.Equal(row.Filters, before.Filters) {
t.Fatal("replay rewrote the credential or filters")
}
for _, body := range []string{
`{"key":"replacement-value"}`,
`{"key":"replacement-value","filters":{"models":["different-model"]}}`,
} {
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
if conflict.Code != http.StatusConflict {
t.Fatalf("omitted or different filters status = %d", conflict.Code)
}
}
})
}
}
func TestEditAccessKeyReplacesCredentialAtomicallyAndReplays(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "original", Key: "original-credential-value", RPMLimit: OptionalRPMLimit{Set: true, Value: 7},
CostLimitRules: OptionalAccessKeyCostLimitRules{Set: true, Values: []AccessKeyCostLimitRuleRequest{{Kind: "total", LimitUSD: "10"}}},
})
if err != nil {
t.Fatal(err)
}
before := loadAccessKeyRow(t, fixture.db, created.ID)
ticket, _ := fixture.service.accessQuota.Admit(created.ID, fixture.service.now())
fixture.service.accessQuota.Complete(ticket, 1234)
quotaBefore := fixture.service.accessQuota.Snapshot(created.ID, fixture.service.now())
engine := newAccessKeyLifecycleEngine(t, fixture)
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
const firstOperation = "00000000-0000-4000-8000-000000008401"
const firstBody = `{"name":"changed","key":"x"}`
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, firstBody, firstOperation)
if first.Code != http.StatusOK {
t.Fatalf("edit with key status = %d", first.Code)
}
data := decodeAccessKeyLifecycleData(t, first)
assertJSONRawEqual(t, data["id"], fmt.Sprint(created.ID))
assertJSONRawEqual(t, data["name"], `"changed"`)
assertJSONRawEqual(t, data["masked_key"], `"********"`)
if _, exists := data["key"]; exists {
t.Fatal("edit metadata exposed plaintext")
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if row.KeyHash != fixture.encryption.Hash("x") || row.KeyValue == before.KeyValue || row.RPMLimit != 7 {
t.Fatal("credential update or policy preservation failed")
}
if _, exists := fixture.manager.Current().AccessKeysByHash[before.KeyHash]; exists {
t.Fatal("old credential remains active")
}
quotaAfter := fixture.service.accessQuota.Snapshot(created.ID, fixture.service.now())
if len(quotaAfter.Rules) != 1 || quotaAfter.Rules[0].UsedNanoUSD != quotaBefore.Rules[0].UsedNanoUSD || quotaAfter.Rules[0].ID != quotaBefore.Rules[0].ID {
t.Fatal("editing the key reset usage or quota identity")
}
second := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"key":"y"}`, "00000000-0000-4000-8000-000000008402")
if second.Code != http.StatusOK {
t.Fatalf("second edit status = %d", second.Code)
}
secondRow := loadAccessKeyRow(t, fixture.db, created.ID)
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, firstBody, firstOperation)
if replayed.Code != http.StatusOK {
t.Fatalf("replay status = %d", replayed.Code)
}
if current := loadAccessKeyRow(t, fixture.db, created.ID); current.KeyValue != secondRow.KeyValue {
t.Fatal("retry overwrote a later credential change")
}
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"name":"changed","key":"z"}`, firstOperation)
if conflict.Code != http.StatusConflict {
t.Fatalf("changed operation body status = %d", conflict.Code)
}
}
func TestEditAccessKeyEmptyPreservesCredentialAndInvalidInputRollsBack(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "original", Key: "original-key-value"})
if err != nil {
t.Fatal(err)
}
if _, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "other", Key: "duplicate-key-value"}); err != nil {
t.Fatal(err)
}
engine := newAccessKeyLifecycleEngine(t, fixture)
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
before := loadAccessKeyRow(t, fixture.db, created.ID)
for _, body := range []string{`{"name":"kept","key":""}`, `{"name":"kept"}`} {
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, "")
if response.Code != http.StatusOK {
t.Fatalf("metadata edit status = %d", response.Code)
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue || row.KeyHash != before.KeyHash {
t.Fatal("empty or omitted key changed the credential")
}
}
for index, test := range []struct {
key string
status int
}{{"duplicate-key-value", 409}, {"two words", 400}, {authTestKey, 400}} {
body, err := json.Marshal(map[string]string{"name": "must-rollback", "key": test.key})
if err != nil {
t.Fatal(err)
}
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, string(body), fmt.Sprintf("00000000-0000-4000-8000-%012d", 8410+index))
if response.Code != test.status {
t.Fatalf("invalid case %d status = %d", index, response.Code)
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.Name != "kept" || row.KeyHash != before.KeyHash {
t.Fatal("rejected key change modified metadata")
}
}
missingOperation := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"key":"replacement-value"}`, "")
if missingOperation.Code != http.StatusPreconditionRequired {
t.Fatal("key change accepted without an operation identity")
}
}
func TestEditAccessKeyRecoversCommittedCredentialChange(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "before", Key: "old-credential-value"})
if err != nil {
t.Fatal(err)
}
before := loadAccessKeyRow(t, fixture.db, created.ID)
publish := fixture.service.publishSnapshot
fail := true
fixture.service.publishSnapshot = func(input state.CompileInput) (*state.ConfigSnapshot, error) {
if fail {
return nil, errors.New("injected publish failure")
}
return publish(input)
}
engine := newAccessKeyLifecycleEngine(t, fixture)
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
const operationID = "00000000-0000-4000-8000-000000008421"
const body = `{"name":"after","key":"new-credential-value"}`
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("failed publish status = %d", response.Code)
}
committed := loadAccessKeyRow(t, fixture.db, created.ID)
if committed.KeyHash == before.KeyHash || committed.Name != "after" {
t.Fatal("transaction was not committed")
}
if _, exists := fixture.manager.Current().AccessKeysByHash[before.KeyHash]; !exists {
t.Fatal("failed publication replaced the snapshot")
}
var operation models.ControlOperation
if err := fixture.db.Where("idempotency_key = ?", operationID).Take(&operation).Error; err != nil {
t.Fatal(err)
}
if operation.OperationKind != "access_key_update" || bytes.Contains(operation.CanonicalResult, []byte("new-credential-value")) {
t.Fatal("invalid or secret-bearing operation metadata")
}
fail = false
response = serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
if response.Code != http.StatusOK {
t.Fatalf("recovery status = %d", response.Code)
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != committed.KeyValue {
t.Fatal("recovery wrote the credential twice")
}
if _, exists := fixture.manager.Current().AccessKeysByHash[committed.KeyHash]; !exists {
t.Fatal("recovery did not publish the new credential")
}
}
+12 -2
View File
@@ -24,6 +24,7 @@ type accessKeyFilterDigestBody struct {
}
type accessKeyCreateDigestBody struct {
KeyHash string `json:"key_hash,omitempty"`
PriceMultiplier string `json:"price_multiplier,omitempty"`
Name string `json:"name"`
Status *state.AccessKeyStatus `json:"status,omitempty"`
@@ -38,6 +39,14 @@ func (s *Service) CreateAccessKeyIdempotent(
idempotencyKey string,
request AccessKeyCreateRequest,
) (AccessKeyCreateResult, error) {
keyHash := ""
if request.Key != "" {
if !validAccessKeyPlaintext(request.Key) {
return AccessKeyCreateResult{}, app_errors.ErrInvalidCustomAccessKey
}
// 使用带密钥的指纹区分请求,避免幂等摘要成为弱密钥的离线猜测凭据。
keyHash = s.encryption.Hash(request.Key)
}
name, err := normalizeAccessKeyName(request.Name)
if err != nil {
return AccessKeyCreateResult{}, err
@@ -74,6 +83,7 @@ func (s *Service) CreateAccessKeyIdempotent(
}
digestFilters := canonicalAccessKeyFilterSet(filters)
canonicalBody, err := canonicalIdempotencyBody(accessKeyCreateDigestBody{
KeyHash: keyHash,
PriceMultiplier: priceMultiplierDigest(priceMultiplier),
Name: name, Status: digestStatus, Filters: digestFilters, RPMLimit: rpmLimit,
CostLimitRules: costLimitRuleRequestsForDigest(costLimitRules),
@@ -111,7 +121,7 @@ func (s *Service) CreateAccessKeyIdempotent(
if err := validateFilterGroupReferences(tx, filters.Groups); err != nil {
return idempotentMutationResult{}, err
}
row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit)
row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit, request.Key)
if err != nil {
return idempotentMutationResult{}, err
}
@@ -126,7 +136,7 @@ func (s *Service) CreateAccessKeyIdempotent(
return idempotentMutationResult{}, err
}
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
ID: row.ID, Name: row.Name, KeyPrefix: *row.KeyPrefix, KeySuffix: row.KeySuffix,
PriceMultiplierMicros: row.PriceMultiplierMicros,
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
ExpiresAtMS: row.ExpiresAtMS,
+78
View File
@@ -0,0 +1,78 @@
package control
import (
"bytes"
"testing"
)
func TestAccessKeyMasksPreserveDefaultFormatAndUseLengthBands(t *testing.T) {
t.Parallel()
for _, test := range []struct{ name, key, mask string }{
{"generated", "", "sk-gl-****0000"},
{"one", "a", "********"},
{"eight", "12345678", "********"},
{"nine", "123456789", "****6789"},
{"sixteen", "1234567890123456", "****3456"},
{"seventeen", "12345678901234567", "123456****4567"},
{"custom prefix", "client-abcdefghijklmnop", "client****mnop"},
{"default prefix", "sk-gl-0123456789abcdef0123456789abcdef", "sk-gl-****cdef"},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: test.name, Key: test.key})
if err != nil {
t.Fatal(err)
}
if created.MaskedKey != test.mask {
t.Fatalf("created mask = %q, want %q", created.MaskedKey, test.mask)
}
spy := &decryptCountingEncryption{Service: fixture.encryption}
fixture.service.encryption = spy
listed, err := fixture.service.ListAccessKeyCollection(t.Context(), AccessKeyCollectionQuery{Page: 1, PageSize: 20})
if err != nil {
t.Fatal(err)
}
if len(listed.Items) != 1 || listed.Items[0].MaskedKey != test.mask {
t.Fatal("collection mask differs from creation")
}
name := "updated"
updated, err := fixture.service.UpdateAccessKey(t.Context(), created.ID, AccessKeyUpdateRequest{Name: &name})
if err != nil {
t.Fatal(err)
}
if updated.MaskedKey != test.mask {
t.Fatal("editing changed the mask")
}
home, err := fixture.service.ReadHomeBase(t.Context(), fixture.service.now().UnixMilli())
if err != nil {
t.Fatal(err)
}
if len(home.AccessKeys) != 1 || home.AccessKeys[0].MaskedKey != test.mask {
t.Fatal("home mask differs from creation")
}
scoped, err := fixture.service.ReadAccessKeyHomeBase(t.Context(), fixture.service.now().UnixMilli(), created.ID)
if err != nil {
t.Fatal(err)
}
if scoped.CurrentAccessKey == nil || scoped.CurrentAccessKey.MaskedKey != test.mask {
t.Fatal("access key home mask differs from creation")
}
if spy.decryptCalls != 0 {
t.Fatal("metadata read or update decrypted a key")
}
fixture.service.random = bytes.NewReader(bytes.Repeat([]byte{1}, 16))
rotated, err := fixture.service.RotateAccessKeyIdempotent(t.Context(), "00000000-0000-4000-8000-000000008301", created.ID)
if err != nil {
t.Fatal(err)
}
if rotated.MaskedKey != "sk-gl-****0101" {
t.Fatal("rotation did not restore the generated key mask")
}
view := fixture.manager.Current().AccessKeysByID[created.ID]
if view.KeyPrefix != "sk-gl-" || view.KeySuffix != "0101" {
t.Fatal("runtime mask metadata differs from rotation")
}
})
}
}
+2 -1
View File
@@ -69,6 +69,7 @@ func (s *Service) RotateAccessKeyIdempotent(
Updates(map[string]any{
"key_value": credential.KeyValue,
"key_hash": credential.KeyHash,
"key_prefix": credential.KeyPrefix,
"key_suffix": credential.KeySuffix,
})
if updated.Error != nil {
@@ -150,7 +151,7 @@ func loadAccessKeyMetadataRow(tx *gorm.DB, id uint) (accessKeyMetadataRow, error
var row accessKeyMetadataRow
if err := tx.Model(&models.AccessKey{}).
Select(
"id", "name", "key_suffix", "status", "filters", "rpm_limit",
"id", "name", "key_prefix", "key_suffix", "status", "filters", "rpm_limit",
"expires_at_ms", "created_at_ms", "updated_at_ms", "price_multiplier_micros",
).
Where("id = ?", id).
@@ -0,0 +1,110 @@
package control
import (
"context"
"encoding/json"
"fmt"
"gorm.io/gorm"
"gpt-load/internal/platform/canonicaljson"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/pricing"
"gpt-load/internal/state"
stateloader "gpt-load/internal/state/loader"
)
// UpdateAccessKeyIdempotent 将密钥与配置更新作为一次操作,重试只恢复原操作。
func (s *Service) UpdateAccessKeyIdempotent(ctx context.Context, idempotencyKey string, id uint, request AccessKeyUpdateRequest) (AccessKeyMetadata, error) {
if request.Key == "" {
return AccessKeyMetadata{}, app_errors.ErrBadRequest
}
mutate, err := s.accessKeyUpdateMutation(id, request)
if err != nil {
return AccessKeyMetadata{}, err
}
digestRequest := request
digestRequest.Key = ""
if request.Name != nil {
name, err := normalizeAccessKeyName(*request.Name)
if err != nil {
return AccessKeyMetadata{}, err
}
digestRequest.Name = &name
}
if request.PriceMultiplier.Set {
multiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
if err != nil {
return AccessKeyMetadata{}, err
}
digestRequest.PriceMultiplier.Value = pricing.FormatPriceMultiplier(multiplier)
}
if request.CostLimitRules.Set {
rules, err := normalizeAccessKeyCostLimitRules(request.CostLimitRules, true)
if err != nil {
return AccessKeyMetadata{}, err
}
digestRequest.CostLimitRules.Values = costLimitRuleRequestsForDigest(rules)
// 编辑摘要必须保留规则 ID,区分保留既有规则与创建新规则。
for index, rule := range rules {
digestRequest.CostLimitRules.Values[index].ID = rule.ID
}
}
if request.Filters != nil {
filters, err := normalizeAccessKeyFilters(request.Filters)
if err != nil {
return AccessKeyMetadata{}, err
}
canonicalFilters := canonicalAccessKeyFilterSet(filters)
digestRequest.Filters = &AccessKeyFilters{
Groups: canonicalFilters.Groups, Protocols: canonicalFilters.Protocols,
Models: canonicalFilters.Models, AllowedCIDRs: canonicalFilters.AllowedCIDRs,
}
}
canonicalBody, err := canonicalIdempotencyBody(struct {
Request AccessKeyUpdateRequest `json:"request"`
KeyHash string `json:"key_hash"`
}{Request: digestRequest, KeyHash: s.encryption.Hash(request.Key)})
if err != nil {
return AccessKeyMetadata{}, app_errors.ErrInternalServer
}
identity := fmt.Sprintf("access-key:%d", id)
digest, err := buildIdempotencyDigest(idempotencyDigestInput{
Version: 1, Method: "PUT", OperationKind: operationKindAccessKeyUpdate,
PathTemplate: "/api/access-keys/:id", ResourceLocator: identity,
AuthScopeID: idempotencyAuthScopeID, CanonicalBody: canonicalBody,
})
if err != nil {
return AccessKeyMetadata{}, app_errors.ErrInternalServer
}
operation, err := s.executeIdempotentOperation(ctx, idempotentOperationInput{
IdempotencyKey: idempotencyKey, DigestVersion: 1, RequestDigest: digest.Digest,
Kind: operationKindAccessKeyUpdate,
Mutate: func(tx *gorm.DB) (idempotentMutationResult, error) {
metadata, err := mutate(tx)
if err != nil {
return idempotentMutationResult{}, err
}
input, err := stateloader.BuildCompileInputWithProxy(ctx, tx, s.encryption, s.environmentProxy, s.channelRegistry)
if err != nil {
return idempotentMutationResult{}, err
}
if _, err := state.Compile(input); err != nil {
return idempotentMutationResult{}, err
}
result, err := canonicaljson.Marshal(metadata)
if err != nil {
return idempotentMutationResult{}, app_errors.ErrInternalServer
}
return idempotentMutationResult{ResourceIdentity: identity, CanonicalResult: result}, nil
},
})
if err != nil {
return AccessKeyMetadata{}, err
}
var result AccessKeyMetadata
if err := json.Unmarshal(operation.CanonicalResult, &result); err != nil {
return AccessKeyMetadata{}, app_errors.ErrInternalServer
}
return result, nil
}
+105 -52
View File
@@ -137,6 +137,7 @@ func (value *OptionalRPMLimit) UnmarshalJSON(data []byte) error {
}
type AccessKeyCreateRequest struct {
Key string `json:"key"`
PriceMultiplier optionalField[string] `json:"price_multiplier"`
Name string `json:"name"`
Status *state.AccessKeyStatus `json:"status"`
@@ -147,6 +148,7 @@ type AccessKeyCreateRequest struct {
}
type AccessKeyUpdateRequest struct {
Key string `json:"key"`
PriceMultiplier optionalField[string] `json:"price_multiplier"`
Name *string `json:"name"`
Status *state.AccessKeyStatus `json:"status"`
@@ -195,6 +197,7 @@ type AccessKeyRevealResult struct {
}
type accessKeyMetadataRow struct {
KeyPrefix string
PriceMultiplierMicros *int64
ID uint
Name string
@@ -208,6 +211,7 @@ type accessKeyMetadataRow struct {
}
type generatedAccessKeyCredential struct {
KeyPrefix string
Plaintext string
KeyValue string
KeyHash string
@@ -220,12 +224,13 @@ func (s *Service) newAccessKeyRow(
name string,
filters AccessKeyFilters,
rpmLimit int64,
customKey string,
) (models.AccessKey, string, error) {
encodedFilters, err := encodeStoredAccessKeyFilters(filters)
if err != nil {
return models.AccessKey{}, "", fmt.Errorf("encode access key filters: %w", err)
}
credential, err := s.generateAccessKeyCredential()
credential, err := s.prepareAccessKeyCredential(customKey)
if err != nil {
return models.AccessKey{}, "", err
}
@@ -233,6 +238,7 @@ func (s *Service) newAccessKeyRow(
Name: name,
KeyValue: credential.KeyValue,
KeyHash: credential.KeyHash,
KeyPrefix: &credential.KeyPrefix,
KeySuffix: credential.KeySuffix,
Status: string(state.AccessKeyStatusActive),
Filters: models.JSON(encodedFilters),
@@ -241,20 +247,39 @@ func (s *Service) newAccessKeyRow(
}
func (s *Service) generateAccessKeyCredential() (generatedAccessKeyCredential, error) {
randomBytes := make([]byte, 16)
if _, err := io.ReadFull(s.random, randomBytes); err != nil {
return generatedAccessKeyCredential{}, fmt.Errorf("generate access key: %w", err)
return s.prepareAccessKeyCredential("")
}
func (s *Service) prepareAccessKeyCredential(plaintext string) (generatedAccessKeyCredential, error) {
if plaintext == "" {
randomBytes := make([]byte, 16)
if _, err := io.ReadFull(s.random, randomBytes); err != nil {
return generatedAccessKeyCredential{}, fmt.Errorf("generate access key: %w", err)
}
plaintext = accessKeyPrefix + hex.EncodeToString(randomBytes)
}
if !validAccessKeyPlaintext(plaintext) {
return generatedAccessKeyCredential{}, app_errors.ErrInvalidCustomAccessKey
}
plaintext := accessKeyPrefix + hex.EncodeToString(randomBytes)
ciphertext, err := s.encryption.Encrypt(plaintext)
if err != nil {
return generatedAccessKeyCredential{}, fmt.Errorf("encrypt access key: %w", err)
}
// 短密钥全部隐藏,避免尾号披露全部或大部分凭据。
suffix := "****"
prefix := ""
if len(plaintext) > 8 {
suffix = plaintext[len(plaintext)-4:]
}
if len(plaintext) > 16 {
prefix = plaintext[:6]
}
return generatedAccessKeyCredential{
Plaintext: plaintext,
KeyValue: ciphertext,
KeyHash: s.encryption.Hash(plaintext),
KeySuffix: plaintext[len(plaintext)-4:],
KeyPrefix: prefix,
KeySuffix: suffix,
}, nil
}
@@ -301,7 +326,7 @@ func (s *Service) CreateAccessKey(
if err := validateFilterGroupReferences(tx, filters.Groups); err != nil {
return err
}
row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit)
row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit, request.Key)
if err != nil {
return err
}
@@ -316,7 +341,7 @@ func (s *Service) CreateAccessKey(
return err
}
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
ID: row.ID, Name: row.Name, KeyPrefix: *row.KeyPrefix, KeySuffix: row.KeySuffix,
PriceMultiplierMicros: row.PriceMultiplierMicros,
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
ExpiresAtMS: row.ExpiresAtMS,
@@ -343,28 +368,48 @@ func (s *Service) UpdateAccessKey(
id uint,
request AccessKeyUpdateRequest,
) (AccessKeyMetadata, error) {
if id == 0 || (request.Name == nil && request.Status == nil && request.Filters == nil &&
mutate, err := s.accessKeyUpdateMutation(id, request)
if err != nil {
return AccessKeyMetadata{}, err
}
var result AccessKeyMetadata
_, err = s.writeConfig(ctx, func(tx *gorm.DB) error {
var mutationErr error
result, mutationErr = mutate(tx)
return mutationErr
}, nil)
if err != nil {
return AccessKeyMetadata{}, err
}
return result, nil
}
func (s *Service) accessKeyUpdateMutation(
id uint,
request AccessKeyUpdateRequest,
) (func(*gorm.DB) (AccessKeyMetadata, error), error) {
if id == 0 || (request.Key == "" && request.Name == nil && request.Status == nil && request.Filters == nil &&
!request.RPMLimit.Set && !request.CostLimitRules.Set && !request.ExpiresAtMS.Set && !request.PriceMultiplier.Set) {
return AccessKeyMetadata{}, app_errors.ErrBadRequest
return nil, app_errors.ErrBadRequest
}
if _, err := normalizeRPMLimit(request.RPMLimit, 0); err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
if request.ExpiresAtMS.Set {
if err := validateOptionalExpiresAtMS(request.ExpiresAtMS.Value); err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
}
priceMultiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
if err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
var desiredCostLimitRules []normalizedAccessKeyCostLimitRule
if request.CostLimitRules.Set {
var err error
desiredCostLimitRules, err = normalizeAccessKeyCostLimitRules(request.CostLimitRules, true)
if err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
}
@@ -372,49 +417,53 @@ func (s *Service) UpdateAccessKey(
if request.Name != nil {
normalized, err := normalizeAccessKeyName(*request.Name)
if err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
name = &normalized
}
if request.Status != nil &&
*request.Status != state.AccessKeyStatusActive &&
*request.Status != state.AccessKeyStatusDisabled {
return AccessKeyMetadata{}, app_errors.ErrValidation
return nil, app_errors.ErrValidation
}
var filters *AccessKeyFilters
var encodedFilters []byte
if request.Filters != nil {
normalized, err := normalizeAccessKeyFilters(request.Filters)
if err != nil {
return AccessKeyMetadata{}, err
return nil, err
}
encoded, err := encodeStoredAccessKeyFilters(normalized)
if err != nil {
return AccessKeyMetadata{}, fmt.Errorf("encode access key filters: %w", err)
return nil, fmt.Errorf("encode access key filters: %w", err)
}
filters = &normalized
encodedFilters = encoded
}
var result AccessKeyMetadata
_, err = s.writeConfig(ctx, func(tx *gorm.DB) error {
if request.Key != "" && !validAccessKeyPlaintext(request.Key) {
return nil, app_errors.ErrInvalidCustomAccessKey
}
return func(tx *gorm.DB) (AccessKeyMetadata, error) {
var result AccessKeyMetadata
var err error
if request.ExpiresAtMS.Set {
if err := validateFutureExpiresAtMS(request.ExpiresAtMS.Value, s.now()); err != nil {
return err
return result, err
}
}
var row accessKeyMetadataRow
if err := tx.Model(&models.AccessKey{}).
Select(
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
"id", "name", "key_prefix", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
"created_at_ms", "updated_at_ms",
).
Where("id = ?", id).
Take(&row).Error; err != nil {
return app_errors.ParseDBError(err)
return result, app_errors.ParseDBError(err)
}
if !validAccessKeySuffix(row.KeySuffix) {
return fmt.Errorf(
if !validAccessKeyPrefix(row.KeyPrefix) || !validAccessKeySuffix(row.KeySuffix) {
return result, fmt.Errorf(
"access key %d has invalid persisted suffix: %w",
row.ID,
app_errors.ErrInternalServer,
@@ -422,7 +471,7 @@ func (s *Service) UpdateAccessKey(
}
currentFilters, err := decodeStoredAccessKeyFilters(row.Filters)
if err != nil {
return fmt.Errorf("decode access key %d filters: %w", row.ID, err)
return result, fmt.Errorf("decode access key %d filters: %w", row.ID, err)
}
if filters != nil {
if err := validateAccessKeyGroupUpdate(
@@ -430,15 +479,25 @@ func (s *Service) UpdateAccessKey(
currentFilters.Groups,
filters.Groups,
); err != nil {
return err
return result, err
}
}
status := state.AccessKeyStatus(row.Status)
if status != state.AccessKeyStatusActive && status != state.AccessKeyStatusDisabled {
return fmt.Errorf("access key %d has invalid status", row.ID)
return result, fmt.Errorf("access key %d has invalid status", row.ID)
}
updates := make(map[string]any, 5)
if request.Key != "" {
credential, err := s.prepareAccessKeyCredential(request.Key)
if err != nil {
return result, err
}
updates["key_value"] = credential.KeyValue
updates["key_hash"] = credential.KeyHash
updates["key_prefix"] = credential.KeyPrefix
updates["key_suffix"] = credential.KeySuffix
}
if request.PriceMultiplier.Set {
row.PriceMultiplierMicros = priceMultiplierStorage(priceMultiplier)
updates["price_multiplier_micros"] = int64(priceMultiplier)
@@ -467,7 +526,7 @@ func (s *Service) UpdateAccessKey(
if err := tx.Model(&models.AccessKey{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return app_errors.ParseDBError(err)
return result, app_errors.ParseDBError(err)
}
}
var costLimitRows []models.AccessKeyCostLimitRule
@@ -477,27 +536,23 @@ func (s *Service) UpdateAccessKey(
costLimitRows, err = loadAccessKeyCostLimitRuleRows(tx, row.ID)
}
if err != nil {
return err
return result, err
}
if err := tx.Model(&models.AccessKey{}).
Select(
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
"id", "name", "key_prefix", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
"created_at_ms", "updated_at_ms",
).
Where("id = ?", row.ID).
Take(&row).Error; err != nil {
return app_errors.ParseDBError(err)
return result, app_errors.ParseDBError(err)
}
result, err = mapAccessKeyMetadataRow(row)
if err == nil {
result.CostLimitRules = mapAccessKeyCostLimitRules(costLimitRows)
}
return err
}, nil)
if err != nil {
return AccessKeyMetadata{}, err
}
return result, nil
return result, err
}, nil
}
func (s *Service) ListAccessKeyOptions(ctx context.Context) ([]AccessKeyOption, error) {
@@ -589,7 +644,7 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error
app_errors.ErrInternalServer,
)
}
if !validAccessKeySuffix(row.KeySuffix) {
if !validAccessKeyPrefix(row.KeyPrefix) || !validAccessKeySuffix(row.KeySuffix) {
return AccessKeyMetadata{}, fmt.Errorf(
"access key %d has invalid persisted suffix: %w",
row.ID,
@@ -607,7 +662,7 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error
return AccessKeyMetadata{
PriceMultiplier: priceMultiplierResponse(row.PriceMultiplierMicros),
ID: row.ID, Name: row.Name,
MaskedKey: maskedAccessKey(row.KeySuffix),
MaskedKey: maskedAccessKey(row.KeyPrefix, row.KeySuffix),
Status: status, Filters: filters, RPMLimit: row.RPMLimit,
ExpiresAtMS: cloneOptionalInt64(row.ExpiresAtMS),
CostLimitRules: []AccessKeyCostLimitRule{},
@@ -615,29 +670,27 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error
}, nil
}
func maskedAccessKey(suffix string) string {
return accessKeyPrefix + "****" + suffix
func maskedAccessKey(prefix, suffix string) string {
return prefix + "****" + suffix
}
func validAccessKeyPrefix(value string) bool {
return value == "" || len(value) == 6 && validAccessKeyPlaintext(value)
}
func validAccessKeySuffix(value string) bool {
if len(value) != 4 {
return false
}
for _, character := range []byte(value) {
if character < '0' || character > '9' && character < 'a' || character > 'f' {
return false
}
}
return true
return validAccessKeyPlaintext(value)
}
func validAccessKeyPlaintext(value string) bool {
if len(value) != len(accessKeyPrefix)+32 ||
!strings.HasPrefix(value, accessKeyPrefix) {
if len(value) == 0 || len(value) > 256 {
return false
}
for _, character := range []byte(strings.TrimPrefix(value, accessKeyPrefix)) {
if character < '0' || character > '9' && character < 'a' || character > 'f' {
for _, character := range []byte(value) {
if character < '!' || character > '~' {
return false
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ func TestAccessKeyMetadataFailsClosedForInvalidPersistedSuffix(t *testing.T) {
}
if err := fixture.db.Model(&models.AccessKey{}).
Where("id = ?", created.ID).
UpdateColumn("key_suffix", "ZZZZ").Error; err != nil {
UpdateColumn("key_suffix", "bad ").Error; err != nil {
t.Fatalf("set invalid suffix: %v", err)
}
if err := fixture.db.Exec("PRAGMA ignore_check_constraints = OFF").Error; err != nil {
+1 -1
View File
@@ -60,7 +60,7 @@ func (s *Service) EnsureInitialState(ctx context.Context) error {
if err != nil {
return fmt.Errorf("build default access key filters: %w", err)
}
row, _, err := s.newAccessKeyRow("Default", filters, 0)
row, _, err := s.newAccessKeyRow("Default", filters, 0, "")
if err != nil {
return err
}
+2 -2
View File
@@ -419,7 +419,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) {
if view.Allowed {
continue
}
if !validAccessKeySuffix(accessKey.KeySuffix) {
if !validAccessKeyPrefix(accessKey.KeyPrefix) || !validAccessKeySuffix(accessKey.KeySuffix) {
return runtimeHealthResponse{}, fmt.Errorf(
"map blocked access key %d suffix: %w",
accessKeyID,
@@ -429,7 +429,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) {
status := mapAccessKeyCostLimitStatus(view)
result.BlockedAccessKeys = append(result.BlockedAccessKeys, healthAccessKeyCostLimitResponse{
AccessKeyID: accessKeyID, Name: accessKey.Name,
MaskedKey: maskedAccessKey(accessKey.KeySuffix),
MaskedKey: maskedAccessKey(accessKey.KeyPrefix, accessKey.KeySuffix),
Recoverable: status.Recoverable,
NextAvailableAtMS: cloneCostLimitMilliseconds(status.NextAvailableAtMS),
BlockingRules: blockingCostLimitRuleStatuses(status),
+5 -4
View File
@@ -58,6 +58,7 @@ type homeCredentialRow struct {
}
type homeAccessKeyRow struct {
KeyPrefix string
PriceMultiplierMicros *int64
ID uint
Name string
@@ -260,7 +261,7 @@ func (s *Service) readHomeRows(
}
if err := tx.Model(&models.AccessKey{}).
Select(
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "price_multiplier_micros",
"id", "name", "key_prefix", "key_suffix", "status", "filters", "rpm_limit", "price_multiplier_micros",
"expires_at_ms",
"created_at_ms", "updated_at_ms",
"(SELECT MAX(request_logs.completed_at_ms) FROM request_logs WHERE request_logs.access_key_id = access_keys.id) AS last_request_at_ms",
@@ -495,7 +496,7 @@ func mapHomeAccessKeys(rows []homeAccessKeyRow) ([]HomeAccessKey, error) {
app_errors.ErrInternalServer,
)
}
if !validAccessKeySuffix(row.KeySuffix) {
if !validAccessKeyPrefix(row.KeyPrefix) || !validAccessKeySuffix(row.KeySuffix) {
return nil, fmt.Errorf(
"map home access key %d: invalid persisted suffix: %w",
row.ID,
@@ -518,7 +519,7 @@ func mapHomeAccessKeys(rows []homeAccessKeyRow) ([]HomeAccessKey, error) {
}
result = append(result, HomeAccessKey{
ID: row.ID, Name: row.Name,
MaskedKey: maskedAccessKey(row.KeySuffix),
MaskedKey: maskedAccessKey(row.KeyPrefix, row.KeySuffix),
Protocols: protocols,
})
}
@@ -541,7 +542,7 @@ func mapHomeCurrentAccessKey(
) (AccessKeyCollectionItem, error) {
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
PriceMultiplierMicros: row.PriceMultiplierMicros,
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
ID: row.ID, Name: row.Name, KeyPrefix: row.KeyPrefix, KeySuffix: row.KeySuffix,
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
ExpiresAtMS: row.ExpiresAtMS,
CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS,
+2 -1
View File
@@ -25,6 +25,7 @@ type operationKind string
const (
operationKindAccessKeyCreate operationKind = "access_key_create"
operationKindAccessKeyRotate operationKind = "access_key_rotate"
operationKindAccessKeyUpdate operationKind = "access_key_update"
operationKindGroupCreate operationKind = "group_create"
operationKindCredentialImport operationKind = "credential_import"
)
@@ -105,7 +106,7 @@ func buildIdempotencyDigest(
func (kind operationKind) valid() bool {
switch kind {
case operationKindAccessKeyCreate, operationKindAccessKeyRotate, operationKindGroupCreate,
case operationKindAccessKeyCreate, operationKindAccessKeyRotate, operationKindAccessKeyUpdate, operationKindGroupCreate,
operationKindCredentialImport:
return true
default:
+1 -1
View File
@@ -71,7 +71,7 @@ type operationExpiredData struct {
func operationRequiredStages(kind operationKind) ([]operationStage, error) {
var stages []operationStage
switch kind {
case operationKindAccessKeyCreate, operationKindAccessKeyRotate:
case operationKindAccessKeyCreate, operationKindAccessKeyRotate, operationKindAccessKeyUpdate:
stages = []operationStage{
operationStageDBCommitted,
operationStageSnapshotPublished,
+1 -1
View File
@@ -270,7 +270,7 @@ func operationGroupID(operation *models.ControlOperation) (uint, error) {
func validateOperationResourceIdentity(kind operationKind, identity string) error {
switch kind {
case operationKindAccessKeyCreate, operationKindAccessKeyRotate:
case operationKindAccessKeyCreate, operationKindAccessKeyRotate, operationKindAccessKeyUpdate:
_, err := parseResourceIdentity(identity, "access-key")
return err
case operationKindGroupCreate, operationKindCredentialImport:
+30 -1
View File
@@ -886,6 +886,13 @@ func (s *Server) handleCreateAccessKey(c *gin.Context) {
writeServiceError(c, "create_access_key", mapControlJSONError(err))
return
}
if request.Key != "" {
digest := sha256.Sum256([]byte(request.Key))
if s.compareDigest(digest[:], s.authDigest[:]) == 1 {
writeServiceError(c, "create_access_key", app_errors.ErrAccessKeyAdminConflict)
return
}
}
result, err := s.service.CreateAccessKeyIdempotent(
c.Request.Context(),
idempotencyKey,
@@ -963,7 +970,22 @@ func (s *Server) handleUpdateAccessKey(c *gin.Context) {
writeServiceError(c, "update_access_key", mapControlJSONError(err))
return
}
result, err := s.service.UpdateAccessKey(c.Request.Context(), id, request)
var result AccessKeyMetadata
var err error
if request.Key != "" {
idempotencyKey, ok := requiredIdempotencyKey(c, "update_access_key")
if !ok {
return
}
digest := sha256.Sum256([]byte(request.Key))
if s.compareDigest(digest[:], s.authDigest[:]) == 1 {
writeServiceError(c, "update_access_key", app_errors.ErrAccessKeyAdminConflict)
return
}
result, err = s.service.UpdateAccessKeyIdempotent(c.Request.Context(), idempotencyKey, id, request)
} else {
result, err = s.service.UpdateAccessKey(c.Request.Context(), id, request)
}
if err != nil {
writeServiceError(c, "update_access_key", err)
return
@@ -1215,7 +1237,14 @@ func serviceErrorMessageID(
}
case app_errors.ErrNoActiveCredential.Code:
return "group.no_active_credential"
case app_errors.ErrInvalidCustomAccessKey.Code:
return "access_key.custom_invalid"
case app_errors.ErrAccessKeyAdminConflict.Code:
return "access_key.admin_conflict"
case app_errors.ErrDuplicateResource.Code:
if operation == "create_access_key" || operation == "update_access_key" {
return "access_key.exists"
}
if operation == "create_group" || operation == "update_group_settings" {
return "group.name_exists"
}
+2
View File
@@ -27,6 +27,8 @@ var (
ErrInvalidJSON = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_JSON", Message: "Invalid JSON format"}
ErrRequestTooLarge = &APIError{HTTPStatus: http.StatusRequestEntityTooLarge, Code: "REQUEST_TOO_LARGE", Message: "Request body is too large"}
ErrValidation = &APIError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_FAILED", Message: "Input validation failed"}
ErrInvalidCustomAccessKey = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_CUSTOM_ACCESS_KEY", Message: "Custom access key contains unsupported characters or exceeds 256 bytes"}
ErrAccessKeyAdminConflict = &APIError{HTTPStatus: http.StatusBadRequest, Code: "ACCESS_KEY_ADMIN_CONFLICT", Message: "Access key must differ from the administrator key"}
ErrDuplicateResource = &APIError{HTTPStatus: http.StatusConflict, Code: "DUPLICATE_RESOURCE", Message: "Resource already exists"}
ErrResourceNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
ErrGroupInUse = &APIError{HTTPStatus: http.StatusConflict, Code: "GROUP_IN_USE", Message: "Group is referenced by access keys"}
+3
View File
@@ -2,6 +2,9 @@ package locales
// MessagesEnUS contains English (US) control-plane translations.
var MessagesEnUS = map[string]string{
"access_key.custom_invalid": "Use up to 256 visible ASCII characters without spaces or control characters.",
"access_key.admin_conflict": "The administrator key cannot be used as an access key.",
"access_key.exists": "This access key already exists. Use a different key.",
"common.success": "Success",
"route.not_found": "Route not found",
"route.method_not_allowed": "Method not allowed",
+3
View File
@@ -2,6 +2,9 @@ package locales
// MessagesJaJP contains Japanese control-plane translations.
var MessagesJaJP = map[string]string{
"access_key.custom_invalid": "空白や制御文字を含まない、256 文字以内の表示可能な ASCII 文字を使用してください。",
"access_key.admin_conflict": "管理者キーをアクセスキーとして使用することはできません。",
"access_key.exists": "このアクセスキーは既に存在します。別のキーを使用してください。",
"common.success": "成功",
"route.not_found": "ルートが見つかりません",
"route.method_not_allowed": "許可されていないHTTPメソッドです",
+3
View File
@@ -2,6 +2,9 @@ package locales
// MessagesZhCN contains Simplified Chinese control-plane translations.
var MessagesZhCN = map[string]string{
"access_key.custom_invalid": "密钥最多 256 个字符,仅支持英文字母、数字和英文符号,不允许空白。",
"access_key.admin_conflict": "不能使用管理员密钥作为访问密钥。",
"access_key.exists": "此访问密钥已存在,请使用其他密钥。",
"common.success": "操作成功",
"route.not_found": "路由不存在",
"route.method_not_allowed": "请求方法不被允许",
+5 -2
View File
@@ -254,7 +254,7 @@ func queryCompileRows(ctx context.Context, db *gorm.DB) (compileRows, error) {
return compileRows{}, fmt.Errorf("query credential metadata: %w", err)
}
if err := db.
Select("id", "name", "key_hash", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros").
Select("id", "name", "key_hash", "key_prefix", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros").
Order("id ASC").
Find(&rows.accessKeys).Error; err != nil {
return compileRows{}, fmt.Errorf("query access keys: %w", err)
@@ -705,9 +705,12 @@ func mapAccessKeys(
if err != nil {
return nil, fmt.Errorf("access key %d: %w", row.ID, err)
}
if row.KeyPrefix == nil {
return nil, fmt.Errorf("access key %d is missing mask prefix metadata", row.ID)
}
result = append(result, state.AccessKeyConfig{
PriceMultiplier: &multiplier,
ID: row.ID, Name: row.Name, KeyHash: row.KeyHash, KeySuffix: row.KeySuffix,
ID: row.ID, Name: row.Name, KeyHash: row.KeyHash, KeyPrefix: *row.KeyPrefix, KeySuffix: row.KeySuffix,
Status: state.AccessKeyStatus(row.Status), Filters: filters.toState(), RPMLimit: row.RPMLimit,
ExpiresAtMS: cloneInt64Pointer(row.ExpiresAtMS), AllowedPeerCIDRs: allowedPeerCIDRs,
CostLimitRules: append([]accessquota.Rule(nil), rulesByAccessKey[row.ID]...),
+3
View File
@@ -72,6 +72,7 @@ func externalModelName(model ModelConfig) string {
}
type AccessKeyConfig struct {
KeyPrefix string
PriceMultiplier *pricing.PriceMultiplier
ID uint
Name string
@@ -154,6 +155,7 @@ type GroupCatalogView struct {
}
type AccessKeyView struct {
KeyPrefix string
PriceMultiplier pricing.PriceMultiplier
ID uint
Name string
@@ -286,6 +288,7 @@ func newAccessKeyView(input AccessKeyConfig) AccessKeyView {
PriceMultiplier: resolvePriceMultiplier(input.PriceMultiplier),
ID: input.ID, Name: input.Name, Status: input.Status,
KeySuffix: input.KeySuffix,
KeyPrefix: input.KeyPrefix,
Filters: cloneFilterSet(input.Filters),
ExpiresAtMS: cloneAccessKeyExpiry(input.ExpiresAtMS),
AllowedPeerCIDRs: cloneAllowedPeerCIDRs(input.AllowedPeerCIDRs),
@@ -0,0 +1,82 @@
package storage
import (
"fmt"
"os"
"testing"
"gorm.io/gorm"
)
func TestAccessKeyPrefixMigrationContract(t *testing.T) {
testAccessKeyPrefixMigration(t, openInternalMigrationTestDatabase)
}
func TestExternalAccessKeyPrefixMigrationContract(t *testing.T) {
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
if dsn == "" {
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
}
testAccessKeyPrefixMigration(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) })
}
func testAccessKeyPrefixMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:11]); err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO access_keys (id, name, key_value, key_hash, key_suffix, status, created_at_ms, updated_at_ms) VALUES (1, 'existing', 'cipher', 'legacy-hash', 'abcd', 'active', 1, 1)").Error; err != nil {
t.Fatal(err)
}
}
if scenario == "interrupted" && len(migrations) > 11 {
entry := migrations[11]
up := entry.Up
entry.Up = func(tx *gorm.DB) error {
if err := up(tx); err != nil {
return err
}
return fmt.Errorf("interrupt after access key prefix DDL")
}
registry := append(append([]migration(nil), migrations[:11]...), entry)
if err := applyMigrationRegistry(db, registry); err == nil {
t.Fatal("interruption succeeded")
}
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
t.Fatal("access key mask prefix is missing")
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if scenario == "fresh" {
if err := db.Exec("INSERT INTO access_keys (id, name, key_value, key_hash, key_suffix, status, created_at_ms, updated_at_ms) VALUES (1, 'default', 'cipher', 'legacy-hash', 'abcd', 'active', 1, 1)").Error; err != nil {
t.Fatal(err)
}
}
var row struct{ KeyPrefix, KeySuffix, KeyValue string }
if err := db.Table("access_keys").Where("id = 1").Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.KeyPrefix != "sk-gl-" || row.KeySuffix != "abcd" || row.KeyValue != "cipher" {
t.Fatal("legacy key mask or ciphertext changed")
}
for _, prefix := range []string{"", "client"} {
if err := db.Table("access_keys").Where("id = 1").Update("key_prefix", prefix).Error; err != nil {
t.Fatal(err)
}
}
for _, prefix := range []any{nil, "abc", "1234567"} {
if err := db.Table("access_keys").Where("id = 1").Update("key_prefix", prefix).Error; err == nil {
t.Fatal("invalid prefix was accepted")
}
}
})
}
}
@@ -0,0 +1,127 @@
package storage
import (
"fmt"
"os"
"testing"
"gorm.io/gorm"
"gpt-load/internal/storage/models"
)
func TestCustomAccessKeyMigrationContract(t *testing.T) {
testCustomAccessKeyMigration(t, openInternalMigrationTestDatabase)
}
func TestExternalCustomAccessKeyMigrationContract(t *testing.T) {
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
if dsn == "" {
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
}
testCustomAccessKeyMigration(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) })
}
func testCustomAccessKeyMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
var key models.AccessKey
var rule models.AccessKeyCostLimitRule
var deletedID uint
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:10]); err != nil {
t.Fatal(err)
}
key = models.AccessKey{Name: "existing", KeyValue: "encrypted-test-value", KeyHash: "existing-hash", KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`)}
if err := db.Omit("KeyPrefix").Create(&key).Error; err != nil {
t.Fatal(err)
}
rule = models.AccessKeyCostLimitRule{AccessKeyID: key.ID, Kind: models.AccessKeyCostLimitKindTotal, LimitNanoUSD: 100, RuleRevision: 1}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
state := models.AccessKeyCostLimitState{RuleID: rule.ID, RuleRevision: 1, UsedNanoUSD: 17, SnapshotVersion: 1}
if err := db.Create(&state).Error; err != nil {
t.Fatal(err)
}
// 删除过的较大 ID 也不能因重建表而被再次分配。
deleted := models.AccessKey{Name: "deleted", KeyValue: "cipher", KeyHash: "deleted-hash", KeySuffix: "dead", Status: "active", Filters: models.JSON(`{}`)}
if err := db.Omit("KeyPrefix").Create(&deleted).Error; err != nil {
t.Fatal(err)
}
deletedID = deleted.ID
if err := db.Delete(&deleted).Error; err != nil {
t.Fatal(err)
}
}
if scenario == "interrupted" && len(migrations) > 10 {
entry := migrations[10]
up := entry.Up
entry.Up = func(tx *gorm.DB) error {
if err := up(tx); err != nil {
return err
}
return fmt.Errorf("interrupt after custom access key DDL")
}
registry := append(append([]migration(nil), migrations[:10]...), entry)
if err := applyMigrationRegistry(db, registry); err == nil {
t.Fatal("interruption succeeded")
}
if db.Dialector.Name() == "sqlite" {
var enabled int
if err := db.Raw("PRAGMA foreign_keys").Scan(&enabled).Error; err != nil || enabled != 1 {
t.Fatal("interrupted migration did not restore foreign keys")
}
}
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if scenario != "fresh" {
var current models.AccessKey
if err := db.Take(&current, key.ID).Error; err != nil {
t.Fatal(err)
}
if current.KeyValue != key.KeyValue || current.KeyHash != key.KeyHash || current.KeySuffix != key.KeySuffix {
t.Fatal("existing credential changed")
}
var currentRule models.AccessKeyCostLimitRule
if err := db.Take(&currentRule, rule.ID).Error; err != nil {
t.Fatal(err)
}
var state models.AccessKeyCostLimitState
if err := db.Take(&state, rule.ID).Error; err != nil {
t.Fatal(err)
}
if currentRule.LimitNanoUSD != 100 || state.UsedNanoUSD != 17 {
t.Fatal("quota data changed")
}
}
custom := models.AccessKey{Name: "custom", KeyValue: "encrypted-custom", KeyHash: "custom-hash", KeySuffix: "Z9-_", Status: "active", Filters: models.JSON(`{}`)}
if err := db.Create(&custom).Error; err != nil {
t.Fatalf("custom suffix rejected: %v", err)
}
if scenario != "fresh" && custom.ID <= deletedID {
t.Fatal("migration reused a deleted access key ID")
}
if err := db.Model(&custom).Update("key_suffix", "****").Error; err != nil {
t.Fatal(err)
}
for _, invalid := range []string{"", "abc", "12345"} {
if err := db.Model(&custom).Update("key_suffix", invalid).Error; err == nil {
t.Fatal("invalid suffix length accepted")
}
}
if db.Dialector.Name() == "sqlite" {
var foreignKeys int
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil || foreignKeys != 1 {
t.Fatal("foreign keys not restored")
}
}
})
}
}
@@ -123,7 +123,7 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
if err := db.Table("schema_migrations").Order("id").Pluck("id", &migrationIDs).Error; err != nil {
t.Fatalf("read migration ledger: %v", err)
}
if len(migrationIDs) != 10 || migrationIDs[0] != "0001_initial" ||
if len(migrationIDs) != 12 || migrationIDs[0] != "0001_initial" ||
migrationIDs[1] != "0002_access_key_cost_limits" ||
migrationIDs[2] != "0003_remove_observation_fresh_until" ||
migrationIDs[3] != "0004_usage_stats_group_activity_index" ||
@@ -131,8 +131,8 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
migrationIDs[5] != "0006_error_decision" ||
migrationIDs[6] != "0007_access_key_lifecycle" ||
migrationIDs[7] != "0008_remove_inject_usage_options" ||
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" {
t.Fatalf("migration ledger = %v, want complete 0001-0010 chain", migrationIDs)
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" || migrationIDs[11] != "0012_access_key_mask_prefix" {
t.Fatalf("migration ledger = %v, want complete 0001-0012 chain", migrationIDs)
}
if !db.Migrator().HasIndex("usage_stats", "idx_usage_stats_group_bucket") {
t.Fatal("usage_stats group activity index is missing")
+2
View File
@@ -659,6 +659,8 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
"0008_remove_inject_usage_options",
"0009_price_multipliers",
"0010_model_cooldown",
"0011_custom_access_keys",
"0012_access_key_mask_prefix",
}
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
+9 -10
View File
@@ -1,7 +1,6 @@
package storage
import (
"context"
"errors"
"fmt"
"regexp"
@@ -10,7 +9,6 @@ import (
"gorm.io/gorm"
"gpt-load/internal/storage/dbtx"
migrationfiles "gpt-load/internal/storage/migrations"
)
@@ -97,6 +95,14 @@ var migrations = []migration{
ID: migrationfiles.ID0010, Up: migrationfiles.Up0010,
Validate: migrationfiles.Validate0010, ValidateRecoverable: migrationfiles.ValidateRecoverable0010,
},
{
ID: migrationfiles.ID0011, Up: migrationfiles.Up0011,
Validate: migrationfiles.Validate0011, ValidateRecoverable: migrationfiles.ValidateRecoverable0011,
},
{
ID: migrationfiles.ID0012, Up: migrationfiles.Up0012,
Validate: migrationfiles.Validate0012, ValidateRecoverable: migrationfiles.ValidateRecoverable0012,
},
}
func applyMigrations(db *gorm.DB) error {
@@ -116,14 +122,7 @@ func applyMigrationRegistry(db *gorm.DB, entries []migration) error {
switch strings.ToLower(db.Dialector.Name()) {
case "sqlite":
// SQLite has no advisory-lock API. BEGIN IMMEDIATE pins a connection and
// serializes competing writers before any schema inspection occurs.
return dbtx.Run(context.Background(), db, dbtx.Options{
Mode: dbtx.Write,
Operation: "database migration",
}, func(transaction *gorm.DB) error {
return applyMigrationsLocked(transaction, entries, false)
})
return applySQLiteMigrationRegistry(db, entries)
case "mysql", "postgres", "postgresql":
return db.Connection(func(connection *gorm.DB) error {
if err := acquireMigrationLock(connection); err != nil {
+72
View File
@@ -0,0 +1,72 @@
package storage
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
// SQLite 重建被外键引用的表前,必须在事务外关闭外键执行,避免 DROP 级联删除子表数据。
// 整条迁移链仍在 BEGIN IMMEDIATE 中串行执行,提交前统一校验全部外键。
func applySQLiteMigrationRegistry(db *gorm.DB, entries []migration) error {
return db.Connection(func(connection *gorm.DB) (resultErr error) {
conn, ok := connection.Statement.ConnPool.(*sql.Conn)
if !ok {
return fmt.Errorf("SQLite migration connection is not pinned")
}
ctx := context.Background()
var foreignKeys int
if err := conn.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
return err
}
active := false
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
var cleanupErr error
if active {
_, cleanupErr = conn.ExecContext(cleanup, "ROLLBACK")
}
restore := "PRAGMA foreign_keys = OFF"
if foreignKeys != 0 {
restore = "PRAGMA foreign_keys = ON"
}
_, err := conn.ExecContext(cleanup, restore)
cleanupErr = errors.Join(cleanupErr, err)
var restored int
if err := conn.QueryRowContext(cleanup, "PRAGMA foreign_keys").Scan(&restored); err != nil {
cleanupErr = errors.Join(cleanupErr, err)
} else if restored != foreignKeys {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("restore SQLite foreign key enforcement failed"))
}
if cleanupErr != nil {
// 丢弃清理失败的连接,不能将未知事务或外键状态交还连接池。
if err := conn.Raw(func(any) error { return driver.ErrBadConn }); err != nil && !errors.Is(err, driver.ErrBadConn) {
cleanupErr = errors.Join(cleanupErr, err)
}
}
resultErr = errors.Join(resultErr, cleanupErr)
}()
if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
return err
}
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
return err
}
active = true
tx := connection.Session(&gorm.Session{NewDB: true, SkipDefaultTransaction: true, Context: ctx})
if err := applyMigrationsLocked(tx, entries, false); err != nil {
return err
}
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
return err
}
active = false
return nil
})
}
+44
View File
@@ -0,0 +1,44 @@
package storage
import (
"testing"
"gorm.io/gorm"
)
func TestSQLiteMigrationRejectsOrphansAndRestoresForeignKeys(t *testing.T) {
db := openInternalMigrationTestDatabase(t)
entry := migration{
ID: "0001_foreign_key_probe",
Up: func(tx *gorm.DB) error {
for _, statement := range []string{
"CREATE TABLE parents (id INTEGER PRIMARY KEY)",
"CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parents(id))",
"INSERT INTO children (id, parent_id) VALUES (1, 99)",
} {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
return nil
},
Validate: func(*gorm.DB) error { return nil },
ValidateRecoverable: func(*gorm.DB) error { return nil },
}
if err := applyMigrationRegistry(db, []migration{entry}); err == nil {
t.Fatal("migration committed an orphan")
}
if db.Migrator().HasTable("parents") || db.Migrator().HasTable("children") || db.Migrator().HasTable("schema_migrations") {
t.Fatal("failed migration did not roll back")
}
var enabled int
if err := db.Raw("PRAGMA foreign_keys").Scan(&enabled).Error; err != nil || enabled != 1 {
t.Fatal("failed migration left foreign key enforcement disabled")
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO access_key_cost_limit_rules (access_key_id, kind, limit_nano_usd, period_seconds, rule_revision, created_at_ms, updated_at_ms) VALUES (999, 'total', 1, 0, 1, 1, 1)").Error; err == nil {
t.Fatal("writes after migration accepted an orphan")
}
}
+2
View File
@@ -22,6 +22,8 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
migrationfiles.ID0008,
migrationfiles.ID0009,
migrationfiles.ID0010,
migrationfiles.ID0011,
migrationfiles.ID0012,
}
if len(migrations) != len(wantIDs) {
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
@@ -16,7 +16,7 @@ func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testin
Name: "legacy", KeyValue: "ciphertext", KeyHash: "legacy-hash",
KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`),
}
if err := db.Omit("ExpiresAtMS", "PriceMultiplierMicros").Create(&accessKey).Error; err != nil {
if err := db.Omit("ExpiresAtMS", "PriceMultiplierMicros", "KeyPrefix").Create(&accessKey).Error; err != nil {
t.Fatalf("create legacy access key: %v", err)
}
@@ -0,0 +1,139 @@
package migrations
import (
"fmt"
"strings"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
)
const ID0011 = "0011_custom_access_keys"
const accessKeySuffixCheck0011 = "chk_access_key_suffix"
const accessKeySuffixExpression0011 = "length(key_suffix) = 4"
const priorAccessKeySuffixExpression0011 = "length(key_suffix) = 4 AND substr(key_suffix, 1, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 2, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 3, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 4, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f')"
// Up0011 保留四位尾号字段,允许自定义字符及短密钥的全遮罩。
func Up0011(db *gorm.DB) error {
if err := ValidateRecoverable0011(db); err != nil {
return err
}
if Validate0011(db) == nil {
return nil
}
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
if err := rebuildAccessKeysSQLite0011(db); err != nil {
return err
}
} else {
drop := "DROP CONSTRAINT"
if dialector, ok := db.Dialector.(*gormmysql.Dialector); ok && dialector.Config != nil && mysqlRequiresCheckDropSyntax0003(dialector.ServerVersion) {
drop = "DROP CHECK"
}
// MySQL 在同一条 DDL 内原子替换,building 标记可安全恢复。
if err := db.Exec("ALTER TABLE access_keys " + drop + " chk_access_key_suffix, ADD CONSTRAINT chk_access_key_suffix CHECK (" + accessKeySuffixExpression0011 + ")").Error; err != nil {
return err
}
}
return Validate0011(db)
}
func ValidateRecoverable0011(db *gorm.DB) error {
if !db.Migrator().HasTable("access_keys") || !db.Migrator().HasColumn("access_keys", "key_suffix") || !db.Migrator().HasConstraint("access_keys", accessKeySuffixCheck0011) {
return fmt.Errorf("custom access key suffix schema is incomplete")
}
return nil
}
func Validate0011(db *gorm.DB) error {
if err := ValidateRecoverable0011(db); err != nil {
return err
}
definition, err := accessKeySuffixConstraint0011(db)
if err != nil {
return err
}
normalized := strings.NewReplacer(" ", "", "\n", "", "\t", "", "`", "", `"`, "", "(", "", ")", "", "::text", "").Replace(strings.ToLower(definition))
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
if !strings.Contains(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4,") && !strings.HasSuffix(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4") {
return fmt.Errorf("custom access key suffix constraint is invalid")
}
} else if normalized != "lengthkey_suffix=4" && normalized != "checklengthkey_suffix=4" {
return fmt.Errorf("custom access key suffix constraint is invalid")
}
return nil
}
func accessKeySuffixConstraint0011(db *gorm.DB) (string, error) {
var definition string
var err error
switch strings.ToLower(db.Dialector.Name()) {
case "sqlite":
err = db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_keys'").Scan(&definition).Error
case "mysql":
err = db.Raw("SELECT CHECK_CLAUSE FROM information_schema.check_constraints WHERE constraint_schema = DATABASE() AND constraint_name = ?", accessKeySuffixCheck0011).Scan(&definition).Error
case "postgres", "postgresql":
err = db.Raw("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = ? AND conrelid = 'access_keys'::regclass", accessKeySuffixCheck0011).Scan(&definition).Error
default:
return "", fmt.Errorf("unsupported custom access key migration driver")
}
return definition, err
}
func rebuildAccessKeysSQLite0011(db *gorm.DB) error {
var foreignKeys int
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil {
return err
}
if foreignKeys != 0 {
return fmt.Errorf("access key rebuild requires migration foreign key isolation")
}
ddl, err := accessKeySuffixConstraint0011(db)
if err != nil {
return err
}
if strings.Count(ddl, priorAccessKeySuffixExpression0011) != 1 {
return fmt.Errorf("unexpected prior access key suffix constraint")
}
start := strings.Index(ddl, "(")
if start < 0 {
return fmt.Errorf("invalid access key table definition")
}
ddl = "CREATE TABLE access_keys__0011 " + strings.Replace(ddl[start:], priorAccessKeySuffixExpression0011, accessKeySuffixExpression0011, 1)
var objects []string
if err := db.Raw("SELECT sql FROM sqlite_master WHERE tbl_name = 'access_keys' AND type IN ('index','trigger') AND sql IS NOT NULL ORDER BY type, name").Scan(&objects).Error; err != nil {
return err
}
var sequence int64
if err := db.Raw("SELECT COALESCE(MAX(seq), 0) FROM sqlite_sequence WHERE name = 'access_keys'").Scan(&sequence).Error; err != nil {
return err
}
columns, err := db.Migrator().ColumnTypes("access_keys")
if err != nil {
return err
}
names := make([]string, 0, len(columns))
for _, column := range columns {
if strings.ContainsAny(column.Name(), "\"`\x00") {
return fmt.Errorf("invalid access key column")
}
names = append(names, `"`+column.Name()+`"`)
}
projection := strings.Join(names, ",")
for _, statement := range []string{ddl,
"INSERT INTO access_keys__0011 (" + projection + ") SELECT " + projection + " FROM access_keys",
"DROP TABLE access_keys", "ALTER TABLE access_keys__0011 RENAME TO access_keys"} {
if err := db.Exec(statement).Error; err != nil {
return err
}
}
if err := db.Exec("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'access_keys'", sequence).Error; err != nil {
return err
}
for _, statement := range objects {
if err := db.Exec(statement).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,71 @@
package migrations
import (
"fmt"
"strings"
"gorm.io/gorm"
)
const ID0012 = "0012_access_key_mask_prefix"
// Up0012 为脱敏前缀保存独立元数据;既有系统生成密钥保留 sk-gl-,列表无需解密。
func Up0012(db *gorm.DB) error {
if err := ValidateRecoverable0012(db); err != nil {
return err
}
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
if err := db.Exec("ALTER TABLE access_keys ADD COLUMN key_prefix VARCHAR(6) NOT NULL DEFAULT 'sk-gl-' CONSTRAINT chk_access_key_prefix CHECK (length(key_prefix) IN (0,6))").Error; err != nil {
return fmt.Errorf("add access key mask prefix: %w", err)
}
}
return Validate0012(db)
}
// ValidateRecoverable0012 接受原子加列前后的状态,以支持 MySQL 的 DDL 中断恢复。
func ValidateRecoverable0012(db *gorm.DB) error {
if !db.Migrator().HasTable("access_keys") {
return fmt.Errorf("access key mask table is missing")
}
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
return nil
}
return validateAccessKeyPrefix0012(db)
}
func Validate0012(db *gorm.DB) error {
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
return fmt.Errorf("access key mask prefix is missing")
}
return validateAccessKeyPrefix0012(db)
}
func validateAccessKeyPrefix0012(db *gorm.DB) error {
if !db.Migrator().HasConstraint("access_keys", "chk_access_key_prefix") {
return fmt.Errorf("access key mask prefix constraint is missing")
}
columns, err := db.Migrator().ColumnTypes("access_keys")
if err != nil {
return err
}
for _, column := range columns {
if column.Name() != "key_prefix" {
continue
}
if !strings.Contains(strings.ToLower(column.DatabaseTypeName()), "char") {
return fmt.Errorf("access key mask prefix must be varchar")
}
if nullable, known := column.Nullable(); !known || nullable {
return fmt.Errorf("access key mask prefix must not be nullable")
}
if length, known := column.Length(); known && length != 6 {
return fmt.Errorf("access key mask prefix length must be six")
}
value, known := column.DefaultValue()
if !known || value != "sk-gl-" && !strings.HasPrefix(value, "'sk-gl-'") {
return fmt.Errorf("access key mask prefix default is invalid")
}
return nil
}
return fmt.Errorf("access key mask prefix is missing")
}
+15 -14
View File
@@ -2,18 +2,19 @@ package models
// AccessKey is an encrypted client credential and its persisted access policy.
type AccessKey struct {
PriceMultiplierMicros *int64 `gorm:"column:price_multiplier_micros;type:bigint;not null;default:1000000;check:chk_access_key_price_multiplier,price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000"`
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(255);not null"`
KeyValue string `gorm:"type:text;not null"`
KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex"`
KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,length(key_suffix) = 4 AND substr(key_suffix, 1, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 2, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 3, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 4, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f')"`
Status string `gorm:"type:varchar(32);not null;default:'active';check:chk_access_key_status,status IN ('active','disabled')"`
Filters JSON `gorm:"type:json"`
RPMLimit int64 `gorm:"not null;default:0"`
DailyCostLimitNanoUSD int64 `gorm:"column:daily_cost_limit_nano_usd;not null;default:0;check:chk_access_key_daily_cost_limit_nano,daily_cost_limit_nano_usd >= 0"`
MonthlyCostLimitNanoUSD int64 `gorm:"column:monthly_cost_limit_nano_usd;not null;default:0;check:chk_access_key_monthly_cost_limit_nano,monthly_cost_limit_nano_usd >= 0"`
ExpiresAtMS *int64 `gorm:"column:expires_at_ms"`
CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli;check:chk_access_key_created_at,created_at_ms >= 0"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli;check:chk_access_key_updated_at,updated_at_ms >= 0"`
PriceMultiplierMicros *int64 `gorm:"column:price_multiplier_micros;type:bigint;not null;default:1000000;check:chk_access_key_price_multiplier,price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000"`
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(255);not null"`
KeyValue string `gorm:"type:text;not null"`
KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex"`
KeyPrefix *string `gorm:"type:varchar(6);not null;default:'sk-gl-';check:chk_access_key_prefix,length(key_prefix) IN (0,6)"`
KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,length(key_suffix) = 4"`
Status string `gorm:"type:varchar(32);not null;default:'active';check:chk_access_key_status,status IN ('active','disabled')"`
Filters JSON `gorm:"type:json"`
RPMLimit int64 `gorm:"not null;default:0"`
DailyCostLimitNanoUSD int64 `gorm:"column:daily_cost_limit_nano_usd;not null;default:0;check:chk_access_key_daily_cost_limit_nano,daily_cost_limit_nano_usd >= 0"`
MonthlyCostLimitNanoUSD int64 `gorm:"column:monthly_cost_limit_nano_usd;not null;default:0;check:chk_access_key_monthly_cost_limit_nano,monthly_cost_limit_nano_usd >= 0"`
ExpiresAtMS *int64 `gorm:"column:expires_at_ms"`
CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli;check:chk_access_key_created_at,created_at_ms >= 0"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli;check:chk_access_key_updated_at,updated_at_ms >= 0"`
}
-5
View File
@@ -594,11 +594,6 @@ export interface AccessKeyCreateResultDto extends AccessKeyDto {
replayed: boolean
}
export interface AccessKeyRotateResultDto extends AccessKeyDto {
key?: string
replayed: boolean
}
export interface AccessKeyRevealDto {
id: number
key: string
+12 -4
View File
@@ -1,7 +1,11 @@
import { ApiError, InvalidResponseError, NetworkError, RequestCancelledError } from '@/api/errors'
type OperationKind =
'access_key_create' | 'access_key_rotate' | 'group_create' | 'credential_import'
| 'access_key_create'
| 'access_key_update'
| 'access_key_rotate'
| 'group_create'
| 'credential_import'
interface IncompleteOperation {
operation_id: string
@@ -37,9 +41,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function isOperationKind(value: unknown): value is OperationKind {
return ['access_key_create', 'access_key_rotate', 'group_create', 'credential_import'].includes(
String(value),
)
return [
'access_key_create',
'access_key_update',
'access_key_rotate',
'group_create',
'credential_import',
].includes(String(value))
}
function incompleteOperation(data: unknown): IncompleteOperation | undefined {
+4 -28
View File
@@ -17,7 +17,6 @@ import type {
AccessKeyFiltersDto,
AccessKeyOptionDto,
AccessKeyRevealDto,
AccessKeyRotateResultDto,
} from '@/api/control/types'
import { knownAccessProtocols } from '@/api/control/protocols'
import { InvalidResponseError } from '@/api/errors'
@@ -48,11 +47,11 @@ export type {
AccessKeyFiltersDto,
AccessKeyOptionDto,
AccessKeyRevealDto,
AccessKeyRotateResultDto,
AccessProtocol,
} from '@/api/control/types'
export interface CreateAccessKeyRequest {
key?: string
name: string
status: AccessKeyDto['status']
filters: AccessKeyFiltersDto
@@ -70,6 +69,7 @@ export interface AccessKeyCostLimitRuleInput {
}
export type UpdateAccessKeyRequest = Partial<{
key: string
name: string
status: AccessKeyDto['status']
filters: AccessKeyFiltersDto
@@ -490,41 +490,17 @@ export async function revealAccessKey(
}
}
export async function rotateAccessKey(
client: ApiClient,
id: number,
idempotencyKey: string,
signal?: AbortSignal,
): Promise<AccessKeyRotateResultDto> {
const record = projectRecord(
await client.request(`/api/access-keys/${id}/rotate`, {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
signal,
}),
)
assertNoSecretLikeFields(record, [...metadataFields, 'key', 'replayed'])
const key = record.key === undefined ? undefined : projectString(record.key)
if (typeof record.replayed !== 'boolean' || record.replayed === (key !== undefined)) {
invalidResponse()
}
const metadata = Object.fromEntries(metadataFields.map((field) => [field, record[field]]))
return {
...projectAccessKeyMetadata(metadata),
...(key === undefined ? {} : { key }),
replayed: record.replayed,
}
}
export async function updateAccessKey(
client: ApiClient,
id: number,
body: UpdateAccessKeyRequest,
signal?: AbortSignal,
idempotencyKey?: string,
): Promise<AccessKeyDto> {
return projectAccessKeyMetadata(
await client.request(`/api/access-keys/${id}`, {
method: 'PUT',
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
json: body,
signal,
}),
-4
View File
@@ -58,10 +58,6 @@ export const mutationInvalidationPlans = {
[controlQueryKeys.accessKeys.options(), controlQueryKeys.home.base()],
[controlQueryKeys.accessKeys.collectionAll],
),
rotate: plan(
[controlQueryKeys.accessKeys.options(), controlQueryKeys.home.base()],
[controlQueryKeys.accessKeys.collectionAll],
),
reset: plan(
[controlQueryKeys.home.base(), controlQueryKeys.health()],
[controlQueryKeys.accessKeys.collectionAll],
+17 -1
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import AppButton from './AppButton.vue'
import AppDialog from './AppDialog.vue'
withDefaults(
const props = withDefaults(
defineProps<{
open: boolean
title: string
@@ -15,6 +17,7 @@ withDefaults(
confirmDisabled?: boolean
dismissible?: boolean
preventCloseAutoFocus?: boolean
focusCancel?: boolean
appearance?: 'default' | 'ledger'
descriptionTone?: 'default' | 'warning'
}>(),
@@ -24,6 +27,7 @@ withDefaults(
confirmDisabled: false,
dismissible: true,
preventCloseAutoFocus: false,
focusCancel: false,
appearance: 'default',
descriptionTone: 'default',
},
@@ -32,6 +36,16 @@ const emit = defineEmits<{
'update:open': [open: boolean]
confirm: []
}>()
const cancelButton = ref<InstanceType<typeof AppButton>>()
function focusInitialControl(event: Event): void {
if (!props.focusCancel) return
const button = cancelButton.value?.$el
if (button instanceof HTMLButtonElement) {
event.preventDefault()
button.focus()
}
}
</script>
<template>
@@ -46,11 +60,13 @@ const emit = defineEmits<{
:tone="tone"
:description-tone="descriptionTone"
@update:open="emit('update:open', $event)"
@open-auto-focus="focusInitialControl"
>
<template v-if="$slots.trigger" #trigger><slot name="trigger" /></template>
<template v-if="$slots.default" #body><slot /></template>
<template #footer>
<AppButton
ref="cancelButton"
variant="secondary"
size="compact"
:disabled="pending || !dismissible"
+5 -1
View File
@@ -31,7 +31,10 @@ const props = withDefaults(
descriptionTone: 'default',
},
)
const emit = defineEmits<{ 'update:open': [open: boolean] }>()
const emit = defineEmits<{
'update:open': [open: boolean]
'open-auto-focus': [event: Event]
}>()
function setOpen(open: boolean): void {
if (!open && !props.dismissible) return
@@ -53,6 +56,7 @@ function guardDismiss(event: Event): void {
class="app-dialog__content"
:class="[`app-dialog__content--${appearance}`, `app-dialog__content--${tone}`]"
@close-auto-focus="preventCloseAutoFocus && $event.preventDefault()"
@open-auto-focus="emit('open-auto-focus', $event)"
@escape-key-down="guardDismiss"
@interact-outside="guardDismiss"
>
+2 -1
View File
@@ -10,7 +10,7 @@ const props = withDefaults(
defineProps<{
modelValue: string
label: string
type?: 'text' | 'number'
type?: 'text' | 'number' | 'password'
id?: string
placeholder?: string
disabled?: boolean
@@ -87,6 +87,7 @@ defineExpose({ focus })
:class="{ 'app-text-input__input--mono': monospace }"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<slot name="trailing" />
<IconButton
v-if="clearLabel && modelValue"
variant="ghost"
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { Dice5, Eye, EyeOff } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AppTextInput from '@/components/ui/AppTextInput.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import IconButton from '@/components/ui/IconButton.vue'
import { estimateAccessKeyStrength, isValidCustomAccessKey } from './access-key-strength'
const props = withDefaults(
defineProps<{
modelValue: string
disabled: boolean
error?: string
editing?: boolean
currentMask?: string
}>(),
{ editing: false, currentMask: '', error: '' },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const { t } = useI18n()
const input = ref<InstanceType<typeof AppTextInput>>()
const visible = ref(false)
const generationFailed = ref(false)
const invalid = computed(() => !isValidCustomAccessKey(props.modelValue))
const strength = computed(() => estimateAccessKeyStrength(props.modelValue))
const filledSegments = computed(() => ({ weak: 1, fair: 2, strong: 3 })[strength.value ?? 'weak'])
watch(
() => props.modelValue,
(value) => {
generationFailed.value = false
if (value === '') visible.value = false
},
)
function focus(): void {
input.value?.focus()
}
function generateKey(): void {
generationFailed.value = false
try {
const bytes = new Uint8Array(16)
globalThis.crypto.getRandomValues(bytes)
const random = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
emit('update:modelValue', `sk-gl-${random}`)
} catch {
generationFailed.value = true
}
}
defineExpose({ focus })
</script>
<template>
<div class="access-key-credential">
<div class="access-key-credential__heading">
<label
id="access-key-custom-label"
for="access-key-custom-value"
class="access-key-credential__label"
>
{{ t('accessKeys.customKey.label') }}
<small>{{ t('accessKeys.drawer.optional') }}</small>
</label>
</div>
<small v-if="editing && currentMask">
{{ t('accessKeys.customKey.current') }} <code>{{ currentMask }}</code>
</small>
<div
class="access-key-credential__input-group"
:class="{ 'access-key-credential__input-group--invalid': invalid || !!error }"
data-input-shell
role="group"
aria-labelledby="access-key-custom-label"
>
<AppTextInput
id="access-key-custom-value"
ref="input"
:model-value="modelValue"
:label="t('accessKeys.customKey.label')"
:type="visible ? 'text' : 'password'"
:placeholder="
t(editing ? 'accessKeys.customKey.editPlaceholder' : 'accessKeys.customKey.placeholder')
"
:disabled="disabled"
:invalid="invalid || !!error"
:spellcheck="false"
autocomplete="new-password"
autocapitalize="none"
described-by="access-key-custom-description"
aria-labelledby="access-key-custom-label"
appearance="surface"
size="compact"
monospace
@update:model-value="emit('update:modelValue', $event)"
>
<template #trailing>
<IconButton
variant="ghost"
size="compact"
:disabled="disabled"
:label="t(visible ? 'accessKeys.customKey.hide' : 'accessKeys.customKey.show')"
:aria-pressed="visible"
@click="visible = !visible"
>
<EyeOff v-if="visible" :size="15" aria-hidden="true" />
<Eye v-else :size="15" aria-hidden="true" />
</IconButton>
</template>
</AppTextInput>
<AppTooltip :content="t('accessKeys.customKey.generate')" :disabled="disabled">
<IconButton
class="access-key-credential__generate"
size="compact"
:disabled="disabled"
:label="t('accessKeys.customKey.generate')"
@click="generateKey"
>
<Dice5 :size="16" aria-hidden="true" />
</IconButton>
</AppTooltip>
</div>
<div
id="access-key-custom-description"
class="access-key-credential__description"
aria-live="polite"
>
<p v-if="generationFailed" class="access-key-credential__error">
{{ t('accessKeys.customKey.generateFailed') }}
</p>
<p v-if="invalid || error" class="access-key-credential__error">
{{ error || t('accessKeys.customKey.invalid') }}
</p>
<template v-else-if="strength">
<div
class="access-key-credential__strength"
:class="`access-key-credential__strength--${strength}`"
>
<span class="access-key-credential__segments" aria-hidden="true">
<span
v-for="segment in 3"
:key="segment"
:class="{ filled: segment <= filledSegments }"
/>
</span>
<span>{{
t('accessKeys.customKey.strength', { level: t(`accessKeys.customKey.${strength}`) })
}}</span>
<small>{{ t('accessKeys.customKey.estimate') }}</small>
</div>
<p v-if="strength === 'weak'">{{ t('accessKeys.customKey.weakHint') }}</p>
<p>{{ t('accessKeys.customKey.saveHint') }}</p>
</template>
<p v-else>
{{ t(editing ? 'accessKeys.customKey.keepHint' : 'accessKeys.customKey.automaticHint') }}
</p>
</div>
</div>
</template>
<style scoped>
.access-key-credential {
display: grid;
gap: 6px;
}
.access-key-credential__heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.access-key-credential__label {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 560;
}
.access-key-credential__input-group {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
}
.access-key-credential__input-group--invalid {
border-color: var(--color-danger);
}
.access-key-credential__input-group :deep(.app-text-input) {
border: 0;
border-radius: var(--radius-control) 0 0 var(--radius-control);
}
.access-key-credential__input-group :deep(.app-text-input[data-input-shell]:focus-within) {
outline: 0;
box-shadow: none;
}
.access-key-credential__generate {
height: 100%;
align-self: stretch;
border: 0;
border-left: 1px solid var(--color-border-subtle);
border-radius: 0 var(--radius-control) var(--radius-control) 0;
}
@media (max-width: 860px) {
.access-key-credential__generate {
width: var(--touch-target);
}
}
.access-key-credential small {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
font-weight: 400;
}
.access-key-credential__label small {
margin-left: var(--space-1);
}
.access-key-credential__description {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.access-key-credential__description p {
margin: 0;
}
.access-key-credential__description .access-key-credential__error {
color: var(--color-danger);
}
.access-key-credential__strength {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.access-key-credential__strength--weak {
color: var(--color-danger);
}
.access-key-credential__strength--fair {
color: var(--color-warning);
}
.access-key-credential__strength--strong {
color: var(--color-success);
}
.access-key-credential__strength + p {
margin-top: 4px;
}
.access-key-credential__segments {
display: flex;
gap: 3px;
width: 60px;
}
.access-key-credential__segments span {
flex: 1;
height: 3px;
border-radius: var(--radius-control);
background: var(--color-border-subtle);
}
.access-key-credential__segments .filled {
background: currentColor;
}
</style>
+181 -57
View File
@@ -12,11 +12,12 @@ import {
} from '@/app/resources/access-keys'
import type { AccessKeyDto, AccessProtocol, GroupOptionDto } from '@/api/control/types'
import type { ChannelDto } from '@/app/resources/channels'
import { RequestCancelledError } from '@/api/errors'
import { ApiError, RequestCancelledError } from '@/api/errors'
import { classifyMutationOutcome } from '@/app/mutation-outcome'
import { applyInvalidationPlan, mutationInvalidationPlans } from '@/app/resources/invalidation'
import { useUnsavedChanges } from '@/app/unsaved-changes'
import AppButton from '@/components/ui/AppButton.vue'
import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue'
import AppDrawer from '@/components/ui/AppDrawer.vue'
import type { SearchableMultiSelectOption } from '@/components/ui/SearchableMultiSelect.vue'
import { createUUID } from '@/lib/uuid'
@@ -35,13 +36,13 @@ import {
findAccessKeyForReconciliation,
type PendingAccessKeyEditOperation,
} from './access-key-edit-operation'
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
import AccessKeyDeleteDialog from './AccessKeyDeleteDialog.vue'
import AccessKeyCostLimitEditor from './AccessKeyCostLimitEditor.vue'
import AccessKeyFormFields from './AccessKeyFormFields.vue'
import AccessKeyCredentialField from './AccessKeyCredentialField.vue'
import { estimateAccessKeyStrength, isValidCustomAccessKey } from './access-key-strength'
import AccessKeyOperationFeedback from './AccessKeyOperationFeedback.vue'
import AccessKeyPolicyFields from './AccessKeyPolicyFields.vue'
import AccessKeyRotateDialog from './AccessKeyRotateDialog.vue'
import AccessKeyScopeEditor from './AccessKeyScopeEditor.vue'
import {
materializeAccessKeyFilters,
@@ -74,13 +75,11 @@ const props = withDefaults(
groupCatalogState?: GroupCatalogState
createOperation?: PendingAccessKeyCreateOperation | null
editOperation?: PendingAccessKeyEditOperation | null
rotateOperation?: PendingAccessKeyRotateOperation | null
}>(),
{
copyFrom: null,
createOperation: null,
editOperation: null,
rotateOperation: null,
groupCatalogState: 'ready',
},
)
@@ -88,9 +87,7 @@ const emit = defineEmits<{
'update:open': [open: boolean]
'update:createOperation': [operation: PendingAccessKeyCreateOperation | null]
'update:editOperation': [operation: PendingAccessKeyEditOperation | null]
'update:rotateOperation': [operation: PendingAccessKeyRotateOperation | null]
saved: [kind: 'created' | 'updated', name: string, accessKey?: AccessKeyDto]
rotated: [name: string]
deleted: [name: string]
}>()
const client = useApiClient()
@@ -101,10 +98,12 @@ const base = ref<AccessKeyDto | null>(null)
const draft = ref<AccessKeyDraft>(createAccessKeyDraft())
const operationID = ref('')
const createPayload = ref<CreateAccessKeyRequest | null>(null)
const credentialField = ref<InstanceType<typeof AccessKeyCredentialField>>()
const keyConfirmationOpen = ref(false)
const customKeyError = ref('')
const createOperationRetained = ref(false)
const editOperationRetained = ref(false)
const pending = ref(false)
const rotationPending = ref(false)
const failed = ref(false)
const mutationState = ref<'idle' | 'indeterminate' | 'reconciling'>('idle')
const editReconciliation = ref<PendingAccessKeyEditOperation | null>(null)
@@ -113,6 +112,22 @@ const modelInput = ref('')
let controller: AbortController | undefined
const editing = computed(() => base.value !== null)
const keyConfirmationDescription = computed(() => {
if (!editing.value) return t('accessKeys.customKey.warningDescription')
const impact = t('accessKeys.customKey.replaceDescription')
return estimateAccessKeyStrength(draft.value.key) === 'weak'
? `${impact} ${t('accessKeys.customKey.replaceWeakWarning')}`
: impact
})
const keyConfirmationLabel = computed(() =>
t(
editing.value
? estimateAccessKeyStrength(draft.value.key) === 'weak'
? 'accessKeys.customKey.saveAnyway'
: 'accessKeys.drawer.saveChanges'
: 'accessKeys.customKey.createAnyway',
),
)
const createOperationActive = computed(
() =>
!editing.value &&
@@ -120,14 +135,9 @@ const createOperationActive = computed(
(mutationState.value !== 'idle' || failed.value),
)
const formLocked = computed(
() =>
pending.value ||
rotationPending.value ||
createOperationActive.value ||
editReconciliation.value !== null ||
props.rotateOperation !== null,
() => pending.value || createOperationActive.value || editReconciliation.value !== null,
)
const closeBlocked = computed(() => pending.value || rotationPending.value)
const closeBlocked = computed(() => pending.value)
const protocolOptions = computed(() => accessKeyProtocolOptions())
const selectedGroupIDs = computed(() =>
draft.value.scopeModes.groups === 'restricted' ? draft.value.filters.groups : [],
@@ -166,14 +176,6 @@ const modelMismatch = computed(
draft.value.filters.models.some((model) => !catalogModelOptions.value.includes(model)),
)
const dirty = computed(() => isAccessKeyDraftDirty(draft.value, base.value))
const rotateActionDisabled = computed(
() =>
pending.value ||
rotationPending.value ||
createOperationActive.value ||
editReconciliation.value !== null ||
dirty.value,
)
const unsavedDirty = computed(
() =>
dirty.value &&
@@ -195,11 +197,17 @@ const scopeValid = computed(() =>
const valid = computed(
() =>
isAccessKeyDraftValid(draft.value, base.value, groupCatalog.value) &&
isValidCustomAccessKey(draft.value.key) &&
!groupProtocolMismatch.value,
)
const mutationFeedbackKey = computed(() => {
if (mutationState.value === 'idle') return ''
if (editReconciliation.value) {
if (editReconciliation.value.idempotencyKey) {
return mutationState.value === 'reconciling'
? 'accessKeys.customKey.editKeyReconciling'
: 'accessKeys.customKey.editKeyIndeterminate'
}
return mutationState.value === 'reconciling'
? 'accessKeys.drawer.editReconciling'
: 'accessKeys.drawer.editIndeterminate'
@@ -246,6 +254,7 @@ const saveBlockerKey = computed(() => {
if (pending.value) return 'accessKeys.drawer.saveBlockedPending'
if (editReconciliation.value || createOperationActive.value) return ''
if (draft.value.name.trim().length === 0) return 'accessKeys.drawer.saveBlockedName'
if (!isValidCustomAccessKey(draft.value.key)) return 'accessKeys.customKey.invalid'
if (!Number.isSafeInteger(draft.value.rpm_limit) || draft.value.rpm_limit < 0) {
return 'accessKeys.drawer.saveBlockedRPM'
}
@@ -288,10 +297,11 @@ function clearLocalState(): void {
draft.value = createAccessKeyDraft()
operationID.value = ''
createPayload.value = null
keyConfirmationOpen.value = false
customKeyError.value = ''
createOperationRetained.value = false
editOperationRetained.value = false
pending.value = false
rotationPending.value = false
failed.value = false
mutationState.value = 'idle'
editReconciliation.value = null
@@ -300,6 +310,8 @@ function clearLocalState(): void {
}
async function resetForOpen(): Promise<void> {
keyConfirmationOpen.value = false
customKeyError.value = ''
const carriedCreateOperation = props.accessKey ? null : props.createOperation
const carriedEditOperation =
props.accessKey && props.editOperation?.base.id === props.accessKey.id
@@ -325,7 +337,7 @@ async function resetForOpen(): Promise<void> {
}))
}
operationID.value = props.accessKey
? ''
? (carriedEditOperation?.idempotencyKey ?? createUUID())
: (carriedCreateOperation?.idempotencyKey ?? createUUID())
createPayload.value = carriedCreateOperation
? cloneAccessKeyCreatePayload(carriedCreateOperation.payload)
@@ -354,12 +366,6 @@ function handleDeleted(name: string): void {
emit('deleted', name)
}
function handleRotated(accessKey: AccessKeyDto): void {
base.value = accessKey
draft.value = createAccessKeyDraft(accessKey)
emit('rotated', accessKey.name)
}
watch(
() => [props.open, props.accessKey, props.copyFrom?.id] as const,
([open]) => {
@@ -449,6 +455,49 @@ function setScopeMode(dimension: AccessKeyScopeDimension, nextMode: AccessKeySco
draft.value.scopeModes[dimension] = nextMode
}
function updateCustomKey(value: string): void {
draft.value.key = value
customKeyError.value = ''
}
function reportCustomKeyError(error: unknown): void {
if (!(error instanceof ApiError)) return
if (error.code === 'DUPLICATE_RESOURCE')
customKeyError.value = t('accessKeys.customKey.duplicate')
if (error.code === 'ACCESS_KEY_ADMIN_CONFLICT')
customKeyError.value = t('accessKeys.customKey.adminConflict')
if (error.code === 'INVALID_CUSTOM_ACCESS_KEY')
customKeyError.value = t('accessKeys.customKey.invalid')
}
async function requestSave(): Promise<void> {
if (pending.value || keyConfirmationOpen.value) return
if (
!createOperationActive.value &&
!editReconciliation.value &&
valid.value &&
dirty.value &&
(editing.value ? draft.value.key !== '' : estimateAccessKeyStrength(draft.value.key) === 'weak')
) {
keyConfirmationOpen.value = true
return
}
await save()
}
async function setKeyConfirmationOpen(open: boolean): Promise<void> {
keyConfirmationOpen.value = open
if (!open) {
await nextTick()
credentialField.value?.focus()
}
}
async function confirmKeyChange(): Promise<void> {
keyConfirmationOpen.value = false
await save()
}
async function save(): Promise<void> {
if (pending.value) {
return
@@ -469,6 +518,7 @@ async function save(): Promise<void> {
createPayload.value = cloneAccessKeyCreatePayload(activeCreatePayload)
}
pending.value = true
customKeyError.value = ''
failed.value = false
editNotApplied.value = false
mutationState.value = 'idle'
@@ -486,6 +536,7 @@ async function save(): Promise<void> {
currentBase.id,
updateBody!,
activeController.signal,
updateBody?.key ? activeOperationID : undefined,
)
if (
controller !== activeController ||
@@ -534,12 +585,16 @@ async function save(): Promise<void> {
return
}
if (error instanceof RequestCancelledError) return
if (updateBody?.key || activeCreatePayload?.key) reportCustomKeyError(error)
const outcome = classifyMutationOutcome({
kind: 'error',
error,
requestSent: true,
})
failed.value = outcome.kind === 'failed'
if (currentBase && outcome.kind === 'failed' && outcome.reason === 'rejected') {
operationID.value = createUUID()
}
if (!currentBase && outcome.kind === 'failed' && outcome.reason === 'rejected') {
operationID.value = createUUID()
createPayload.value = null
@@ -559,23 +614,30 @@ async function save(): Promise<void> {
} else if (
currentBase &&
updateBody &&
(outcome.kind === 'indeterminate' || outcome.kind === 'reconciling')
(outcome.kind === 'indeterminate' ||
outcome.kind === 'reconciling' ||
(!!updateBody.key &&
outcome.kind === 'failed' &&
outcome.reason === 'retryable-precondition'))
) {
const operation: PendingAccessKeyEditOperation = {
base: currentBase,
patch: updateBody,
state: outcome.kind,
...(updateBody.key ? { idempotencyKey: activeOperationID } : {}),
state: outcome.kind === 'failed' ? 'reconciling' : outcome.kind,
}
failed.value = false
editReconciliation.value = operation
editOperationRetained.value = true
emit('update:editOperation', operation)
}
mutationState.value =
outcome.kind === 'reconciling'
editReconciliation.value?.state ??
(outcome.kind === 'reconciling'
? 'reconciling'
: outcome.kind === 'indeterminate'
? 'indeterminate'
: 'idle'
: 'idle')
} finally {
if (controller === activeController) {
controller = undefined
@@ -596,11 +658,38 @@ async function reconcileEdit(): Promise<void> {
const activeController = controller
let confirmedName: string | null = null
try {
const latest = await findAccessKeyForReconciliation(
client,
attempt.base.id,
activeController.signal,
)
let latest: AccessKeyDto | undefined
if (attempt.idempotencyKey) {
try {
latest = await updateAccessKey(
client,
attempt.base.id,
attempt.patch,
activeController.signal,
attempt.idempotencyKey,
)
} catch (error: unknown) {
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
if (
outcome.kind !== 'failed' ||
outcome.reason !== 'expired-known' ||
outcome.resource_identity !== `access-key:${attempt.base.id}`
)
throw error
// 完成记录已过期时只读取当前状态,不能重新执行旧的密钥替换。
latest = await findAccessKeyForReconciliation(
client,
attempt.base.id,
activeController.signal,
)
}
} else {
latest = await findAccessKeyForReconciliation(
client,
attempt.base.id,
activeController.signal,
)
}
if (controller !== activeController || editReconciliation.value !== attempt || !props.open) {
return
}
@@ -620,7 +709,10 @@ async function reconcileEdit(): Promise<void> {
failed.value = true
return
}
if (accessKeyMatchesUpdatePatch(latest, attempt.patch, attempt.base)) {
if (
attempt.idempotencyKey ||
accessKeyMatchesUpdatePatch(latest, attempt.patch, attempt.base)
) {
base.value = latest
draft.value = createAccessKeyDraft(latest)
editReconciliation.value = null
@@ -660,13 +752,28 @@ async function reconcileEdit(): Promise<void> {
editReconciliation.value === attempt &&
!(error instanceof RequestCancelledError)
) {
const operation: PendingAccessKeyEditOperation = {
...attempt,
state: 'indeterminate',
if (attempt.patch.key) reportCustomKeyError(error)
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
if (attempt.idempotencyKey && outcome.kind === 'failed' && outcome.reason === 'rejected') {
editReconciliation.value = null
editOperationRetained.value = false
emit('update:editOperation', null)
operationID.value = createUUID()
mutationState.value = 'idle'
failed.value = true
} else {
const operation: PendingAccessKeyEditOperation = {
...attempt,
state:
outcome.kind === 'reconciling' ||
(outcome.kind === 'failed' && outcome.reason === 'retryable-precondition')
? 'reconciling'
: 'indeterminate',
}
editReconciliation.value = operation
emit('update:editOperation', operation)
mutationState.value = operation.state
}
editReconciliation.value = operation
emit('update:editOperation', operation)
mutationState.value = operation.state
}
} finally {
if (controller === activeController) {
@@ -694,7 +801,7 @@ onBeforeUnmount(clearLocalState)
>
<template #trigger><slot name="trigger" /></template>
<form id="access-key-drawer-form" class="access-key-drawer" @submit.prevent="save">
<form id="access-key-drawer-form" class="access-key-drawer" @submit.prevent="requestSave">
<AccessKeyOperationFeedback
:failed="failed"
:edit-not-applied="editNotApplied"
@@ -715,7 +822,19 @@ onBeforeUnmount(clearLocalState)
@update:status="draft.status = $event"
@update:rpm-limit="draft.rpm_limit = $event"
@update:price-multiplier="draft.price_multiplier = $event"
/>
>
<template #credential>
<AccessKeyCredentialField
ref="credentialField"
:model-value="draft.key"
:editing="editing"
:current-mask="base?.masked_key"
:disabled="formLocked"
:error="customKeyError"
@update:model-value="updateCustomKey"
/>
</template>
</AccessKeyFormFields>
</section>
<section class="drawer-section">
@@ -781,14 +900,6 @@ onBeforeUnmount(clearLocalState)
<template #footer>
<div v-if="editing && base" class="access-key-drawer__management">
<AccessKeyRotateDialog
:access-key="base"
:disabled="rotateActionDisabled"
:operation="rotateOperation"
@update:pending="rotationPending = $event"
@update:operation="emit('update:rotateOperation', $event)"
@rotated="handleRotated"
/>
<AccessKeyDeleteDialog
:access-key="base"
:total="total"
@@ -833,6 +944,19 @@ onBeforeUnmount(clearLocalState)
</AppButton>
</template>
</AppDrawer>
<AppConfirmDialog
:open="keyConfirmationOpen"
:title="t(editing ? 'accessKeys.customKey.replaceTitle' : 'accessKeys.customKey.warningTitle')"
:description="keyConfirmationDescription"
:close-label="t('accessKeys.customKey.warningClose')"
:cancel-label="t('accessKeys.customKey.returnToEdit')"
:confirm-label="keyConfirmationLabel"
description-tone="warning"
focus-cancel
prevent-close-auto-focus
@update:open="setKeyConfirmationOpen"
@confirm="confirmKeyChange"
/>
</template>
<style scoped>
@@ -57,6 +57,8 @@ defineExpose({ focusName })
/>
</div>
<slot name="credential" />
<div class="access-key-drawer__field">
<span class="access-key-drawer__field-label" aria-hidden="true">
{{ t('common.priceMultiplier.label') }}
@@ -1,298 +0,0 @@
<script setup lang="ts">
import { RotateCcw } from '@lucide/vue'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useApiClient } from '@/api/client-context'
import type { AccessKeyDto, AccessKeyRotateResultDto } from '@/api/control/types'
import { RequestCancelledError } from '@/api/errors'
import { classifyMutationOutcome } from '@/app/mutation-outcome'
import { accessKeyResources, revealAccessKey, rotateAccessKey } from '@/app/resources/access-keys'
import { applyInvalidationPlan, mutationInvalidationPlans } from '@/app/resources/invalidation'
import AppButton from '@/components/ui/AppButton.vue'
import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue'
import CopyChip from '@/components/ui/CopyChip.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import { createUUID } from '@/lib/uuid'
import { findAccessKeyForReconciliation } from './access-key-edit-operation'
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
const props = withDefaults(
defineProps<{
accessKey: AccessKeyDto
disabled?: boolean
operation?: PendingAccessKeyRotateOperation | null
}>(),
{ disabled: false, operation: null },
)
const emit = defineEmits<{
rotated: [accessKey: AccessKeyDto]
'update:pending': [pending: boolean]
'update:operation': [operation: PendingAccessKeyRotateOperation | null]
}>()
const client = useApiClient()
const queryClient = useQueryClient()
const { t } = useI18n()
const open = ref(false)
const pending = ref(false)
const failed = ref(false)
const refreshFailed = ref(false)
const operationState = ref<'idle' | 'indeterminate' | 'reconciling'>(
props.operation?.state ?? 'idle',
)
const operationID = ref(props.operation?.idempotencyKey ?? createUUID())
const result = ref<AccessKeyRotateResultDto | null>(null)
const current = ref<AccessKeyDto | null>(null)
let controller: AbortController | undefined
const feedbackKey = computed(() => {
if (operationState.value === 'reconciling') return 'accessKeys.rotate.reconciling'
if (operationState.value === 'indeterminate') return 'accessKeys.rotate.indeterminate'
return ''
})
const confirmLabel = computed(() => {
if (result.value) return t('accessKeys.rotate.done')
if (operationState.value !== 'idle') return t('accessKeys.rotate.checkResult')
return t('accessKeys.rotate.confirm')
})
const triggerLabel = computed(() =>
operationState.value === 'idle'
? t('accessKeys.rotate.open')
: t('accessKeys.rotate.checkResult'),
)
const displayKey = computed(() =>
result.value?.key ? result.value.key : (current.value?.masked_key ?? props.accessKey.masked_key),
)
watch(pending, (value) => emit('update:pending', value))
watch(
() => props.operation,
(operation) => {
if (pending.value || result.value) return
if (operation) {
operationID.value = operation.idempotencyKey
operationState.value = operation.state
} else if (operationState.value !== 'idle') {
operationID.value = createUUID()
operationState.value = 'idle'
}
},
)
function resetOperation(): void {
controller?.abort()
controller = undefined
pending.value = false
failed.value = false
refreshFailed.value = false
operationState.value = 'idle'
operationID.value = createUUID()
result.value = null
current.value = null
emit('update:operation', null)
}
function setOpen(value: boolean): void {
if (!value && pending.value) return
const refreshAfterClose = !value && result.value !== null
if (refreshAfterClose) resetOperation()
open.value = value
if (refreshAfterClose) void refreshQueries()
}
function openDialog(): void {
if (props.disabled) return
open.value = true
}
async function refreshCurrent(signal: AbortSignal): Promise<AccessKeyDto | null> {
try {
return (await findAccessKeyForReconciliation(client, props.accessKey.id, signal)) ?? null
} catch {
refreshFailed.value = true
return null
}
}
async function resolveCurrentKey(): Promise<string> {
const value = await revealAccessKey(client, props.accessKey.id)
return value.key
}
async function refreshQueries(): Promise<void> {
try {
await applyInvalidationPlan(queryClient, mutationInvalidationPlans.accessKey.rotate)
} catch {
void queryClient.invalidateQueries({ queryKey: accessKeyResources.collection.queryKey })
}
}
async function completeRotation(
rotation: AccessKeyRotateResultDto,
signal: AbortSignal,
): Promise<void> {
result.value = rotation
current.value = rotation
if (rotation.replayed) {
current.value = (await refreshCurrent(signal)) ?? rotation
}
emit('rotated', current.value)
emit('update:operation', null)
operationState.value = 'idle'
}
async function confirmRotate(): Promise<void> {
if (pending.value) return
if (result.value) {
setOpen(false)
return
}
pending.value = true
failed.value = false
refreshFailed.value = false
controller?.abort()
controller = new AbortController()
const activeController = controller
try {
const rotation = await rotateAccessKey(
client,
props.accessKey.id,
operationID.value,
activeController.signal,
)
if (controller !== activeController || !open.value) return
await completeRotation(rotation, activeController.signal)
} catch (error: unknown) {
if (controller !== activeController || !open.value || error instanceof RequestCancelledError) {
return
}
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
if (outcome.kind === 'indeterminate' || outcome.kind === 'reconciling') {
operationState.value = outcome.kind
emit('update:operation', {
base: props.accessKey,
idempotencyKey: operationID.value,
state: outcome.kind,
})
} else if (outcome.kind === 'failed' && outcome.reason === 'expired-known') {
const latest = await refreshCurrent(activeController.signal)
if (latest) {
current.value = latest
result.value = { ...latest, replayed: true }
emit('rotated', latest)
emit('update:operation', null)
operationState.value = 'idle'
} else {
operationState.value = 'indeterminate'
emit('update:operation', {
base: props.accessKey,
idempotencyKey: operationID.value,
state: 'indeterminate',
})
}
} else if (outcome.kind === 'failed' && outcome.reason === 'retryable-precondition') {
operationState.value = 'reconciling'
emit('update:operation', {
base: props.accessKey,
idempotencyKey: operationID.value,
state: 'reconciling',
})
} else {
failed.value = true
if (outcome.kind === 'failed' && outcome.reason === 'rejected') {
operationID.value = createUUID()
operationState.value = 'idle'
emit('update:operation', null)
}
}
} finally {
if (controller === activeController) {
controller = undefined
pending.value = false
}
}
}
onBeforeUnmount(() => controller?.abort())
</script>
<template>
<AppConfirmDialog
:open="open"
:title="t('accessKeys.rotate.title')"
:description="t('accessKeys.rotate.description', { name: accessKey.name })"
:close-label="t('accessKeys.rotate.close')"
:cancel-label="result ? t('common.close') : t('common.cancel')"
:confirm-label="confirmLabel"
tone="danger"
description-tone="warning"
:pending="pending"
@update:open="setOpen"
@confirm="confirmRotate"
>
<template #trigger>
<AppButton variant="secondary" size="compact" :disabled="disabled" @click="openDialog">
<RotateCcw :size="16" aria-hidden="true" />
{{ triggerLabel }}
</AppButton>
</template>
<div class="access-key-rotate__body">
<InlineFeedback tone="warning" appearance="hint">
{{ t('accessKeys.rotate.impact') }}
</InlineFeedback>
<InlineFeedback v-if="feedbackKey" tone="warning">
{{ t(feedbackKey) }}
</InlineFeedback>
<InlineFeedback v-if="failed" tone="danger">{{
t('accessKeys.rotate.failed')
}}</InlineFeedback>
<InlineFeedback v-if="refreshFailed" tone="warning">
{{ t('accessKeys.rotate.refreshFailed') }}
</InlineFeedback>
<div v-if="result" class="access-key-rotate__result" aria-live="polite">
<strong>{{
t(result.key ? 'accessKeys.rotate.newKey' : 'accessKeys.rotate.currentKey')
}}</strong>
<CopyChip
v-if="open"
:key="`${accessKey.id}:${result.updated_at_ms}`"
:value="displayKey"
:label="t('accessKeys.copy')"
:success-label="t('common.copied')"
:failure-label="t('common.copyFailed')"
:resolve-value="result.key ? undefined : resolveCurrentKey"
/>
<small>{{
t(result.key ? 'accessKeys.rotate.newKeyHint' : 'accessKeys.rotate.replayedHint')
}}</small>
</div>
</div>
</AppConfirmDialog>
</template>
<style scoped>
.access-key-rotate__body,
.access-key-rotate__result {
display: grid;
gap: 10px;
}
.access-key-rotate__result {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
padding: 10px 12px;
}
.access-key-rotate__result strong {
font-size: var(--text-sm);
}
.access-key-rotate__result small {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
</style>
@@ -52,7 +52,6 @@ import {
type AccessKeyDrawerRoute,
} from './access-key-collection-route'
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
const client = useApiClient()
const route = useRoute()
@@ -67,14 +66,13 @@ const drawerOpen = computed(() => drawerRoute.value !== undefined)
const selected = ref<AccessKeyDto | null>(null)
const createOperation = ref<PendingAccessKeyCreateOperation | null>(null)
const editOperation = ref<PendingAccessKeyEditOperation | null>(null)
const rotateOperation = ref<PendingAccessKeyRotateOperation | null>(null)
const viewRoot = ref<HTMLElement | null>(null)
const collection = ref<InstanceType<typeof AccessKeyCollection> | null>(null)
const deletionAnnouncement = ref('')
const pendingStatusIDs = ref(new Set<number>())
const optimisticEnabled = ref(new Map<number, boolean>())
const lockedAccessKeyIDs = computed<ReadonlySet<number>>(() =>
rotateOperation.value ? new Set([rotateOperation.value.base.id]) : new Set<number>(),
editOperation.value ? new Set([editOperation.value.base.id]) : new Set<number>(),
)
const statusControllers = new Map<number, AbortController>()
const accessKeysQuery = useQuery(accessKeyCollectionQueryOptions(client, filters))
@@ -165,11 +163,6 @@ const editOperationNoticeKey = computed(() =>
const editOperationName = computed(
() => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '',
)
const rotateOperationNoticeKey = computed(() =>
rotateOperation.value?.state === 'reconciling'
? 'accessKeys.operation.rotateReconciling'
: 'accessKeys.operation.rotateIndeterminate',
)
let restoreFocus: HTMLElement | null = null
const searchDebounce = useDebouncedAction(300)
let mounted = true
@@ -193,14 +186,8 @@ watch(
watch(filters, () => collection.value?.conceal())
watch(
[
drawerRoute,
data,
editOperation,
rotateOperation,
() => accessKeysQuery.isPlaceholderData.value,
],
([drawer, pageData, pendingEdit, pendingRotate, placeholder]) => {
[drawerRoute, data, editOperation, () => accessKeysQuery.isPlaceholderData.value],
([drawer, pageData, pendingEdit, placeholder]) => {
if (drawer === undefined || drawer.mode === 'create') {
selected.value = null
if (drawer?.mode === 'create' && drawer.sourceAccessKeyID !== undefined) {
@@ -221,10 +208,6 @@ watch(
selected.value = pendingEdit.base
return
}
if (pendingRotate?.base.id === drawer.accessKeyID) {
selected.value = pendingRotate.base
return
}
if (pageData && !placeholder) void setDrawerRoute(undefined, true)
},
{ immediate: true },
@@ -325,10 +308,6 @@ function openKey(accessKey: AccessKeyDto, trigger: HTMLElement): void {
checkEditOperation()
return
}
if (rotateOperation.value && rotateOperation.value.base.id !== accessKey.id) {
checkRotateOperation()
return
}
selected.value = accessKey
restoreFocus = trigger
void setDrawerRoute({ mode: 'edit', accessKeyID: accessKey.id })
@@ -342,10 +321,6 @@ function setEditOperation(operation: PendingAccessKeyEditOperation | null): void
editOperation.value = operation
}
function setRotateOperation(operation: PendingAccessKeyRotateOperation | null): void {
rotateOperation.value = operation
}
function checkCreateOperation(): void {
if (!createOperation.value) return
selected.value = null
@@ -362,15 +337,6 @@ function checkEditOperation(): void {
void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id })
}
function checkRotateOperation(): void {
const operation = rotateOperation.value
if (!operation) return
selected.value =
data.value?.items.find((accessKey) => accessKey.id === operation.base.id) ?? operation.base
restoreFocus = null
void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id })
}
async function setDrawerOpen(open: boolean): Promise<void> {
if (open) return
collection.value?.conceal()
@@ -418,10 +384,6 @@ async function handleCostLimitsReset(name: string): Promise<void> {
if (mounted) toast.show({ message: t('accessKeys.toast.reset', { name }) })
}
function handleRotated(name: string): void {
toast.show({ message: t('accessKeys.toast.rotated', { name }) })
}
function setStatusPending(id: number, pending: boolean): void {
const next = new Set(pendingStatusIDs.value)
if (pending) next.add(id)
@@ -516,13 +478,10 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise<
:group-catalog-state="groupCatalogState"
:create-operation="createOperation"
:edit-operation="selected?.id === editOperation?.base.id ? editOperation : null"
:rotate-operation="selected?.id === rotateOperation?.base.id ? rotateOperation : null"
@update:create-operation="setCreateOperation"
@update:edit-operation="setEditOperation"
@update:rotate-operation="setRotateOperation"
@update:open="setDrawerOpen"
@saved="handleSaved"
@rotated="handleRotated"
@deleted="handleDeleted"
/>
@@ -542,15 +501,6 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise<
</AppButton>
</section>
<section v-if="rotateOperation" class="access-keys__operation" aria-live="polite">
<InlineFeedback tone="warning">{{
t(rotateOperationNoticeKey, { name: rotateOperation.base.name })
}}</InlineFeedback>
<AppButton variant="secondary" @click="checkRotateOperation">
{{ t('accessKeys.operation.checkResult') }}
</AppButton>
</section>
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ deletionAnnouncement }}</p>
<SkeletonSurface
@@ -10,6 +10,7 @@ export function cloneAccessKeyCreatePayload(
payload: CreateAccessKeyRequest,
): CreateAccessKeyRequest {
return {
...(payload.key ? { key: payload.key } : {}),
name: payload.name,
status: payload.status,
filters: {
@@ -3,6 +3,7 @@ import type { AccessKeyDto } from '@/api/control/types'
import { listAccessKeyCollection, type UpdateAccessKeyRequest } from '@/app/resources/access-keys'
export interface PendingAccessKeyEditOperation {
idempotencyKey?: string
base: AccessKeyDto
patch: UpdateAccessKeyRequest
state: 'indeterminate' | 'reconciling'
@@ -16,6 +16,7 @@ import {
} from './access-key-scope'
export interface AccessKeyDraft {
key: string
name: string
status: AccessKeyDto['status']
filters: AccessKeyFiltersDto
@@ -70,6 +71,7 @@ export function createAccessKeyDraft(accessKey?: AccessKeyDto | null): AccessKey
)
return {
name: accessKey?.name ?? '',
key: '',
status: accessKey?.status ?? 'active',
filters,
scopeModes: createAccessKeyScopeModes(filters),
@@ -86,6 +88,7 @@ export function createAccessKeyDraftFromCreateInput(input: CreateAccessKeyReques
const filters = normalizeAccessKeyFilters(input.filters)
return {
name: input.name,
key: input.key ?? '',
status: input.status,
filters,
scopeModes: createAccessKeyScopeModes(filters),
@@ -106,6 +109,7 @@ export function createAccessKeyDraftFromUpdate(
const expiresAt = patch.expires_at_ms !== undefined ? patch.expires_at_ms : base.expires_at_ms
return {
name: patch.name ?? base.name,
key: patch.key ?? '',
status: patch.status ?? base.status,
filters,
scopeModes: createAccessKeyScopeModes(filters),
@@ -154,6 +158,7 @@ export function isAccessKeyDraftValid(
export function buildCreateAccessKeyInput(draft: AccessKeyDraft): CreateAccessKeyRequest {
return {
...(draft.key ? { key: draft.key } : {}),
name: draft.name.trim(),
status: draft.status,
filters: materializeDraftFilters(draft),
@@ -277,6 +282,7 @@ export function isAccessKeyDraftDirty(draft: AccessKeyDraft, base?: AccessKeyDto
if (base) return Object.keys(buildAccessKeyUpdatePatch(base, draft)).length > 0
return (
draft.name !== initial.name ||
draft.key !== initial.key ||
draft.status !== initial.status ||
draft.expires_at_ms !== initial.expires_at_ms ||
draft.rpm_limit !== initial.rpm_limit ||
@@ -295,6 +301,7 @@ export function accessKeyMatchesUpdatePatch(
base: AccessKeyDto,
): boolean {
return (
!patch.key &&
(patch.name === undefined || patch.name === accessKey.name) &&
(patch.status === undefined || patch.status === accessKey.status) &&
(patch.filters === undefined || equalFilters(patch.filters, accessKey.filters)) &&
@@ -368,6 +375,7 @@ export function buildAccessKeyUpdatePatch(
draft: AccessKeyDraft,
): UpdateAccessKeyRequest {
const patch: UpdateAccessKeyRequest = {}
if (draft.key !== '') patch.key = draft.key
const name = draft.name.trim()
const filters = materializeDraftFilters(draft)
const expiresAt = expirationValue(draft)
@@ -1,7 +0,0 @@
import type { AccessKeyDto } from '@/api/control/types'
export interface PendingAccessKeyRotateOperation {
base: AccessKeyDto
idempotencyKey: string
state: 'indeterminate' | 'reconciling'
}
@@ -0,0 +1,29 @@
export type AccessKeyStrength = 'weak' | 'fair' | 'strong'
export function isValidCustomAccessKey(value: string): boolean {
return value === '' || /^[\x21-\x7e]{1,256}$/.test(value)
}
// 仅用于前端提示,不作为允许创建密钥的强度门槛。
export function estimateAccessKeyStrength(value: string): AccessKeyStrength | null {
if (value === '') return null
const content = value.startsWith('sk-gl-') ? value.slice(6) : value
const lower = content.toLowerCase()
const sequential = [
'012345678901234567890',
'abcdefghijklmnopqrstuvwxyz',
'qwertyuiopasdfghjklzxcvbnm',
].some((sequence) => sequence.includes(lower))
if (
content.length < 12 ||
new Set(content).size < 4 ||
/^(.{1,4})\1+$/.test(content) ||
sequential
) {
return 'weak'
}
const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z0-9]/].filter((pattern) =>
pattern.test(content),
).length
return content.length >= 20 || (content.length >= 16 && kinds >= 3) ? 'strong' : 'fair'
}
+38 -27
View File
@@ -1,5 +1,43 @@
export default {
accessKeys: {
customKey: {
generate: 'Generate randomly',
current: 'Current key:',
editPlaceholder: 'Enter a new key, or leave blank to keep it',
keepHint: 'Leave blank to keep the current key.',
saveHint: 'The new key takes effect only after saving successfully.',
generateFailed: 'Unable to generate a secure random key. Retry or enter a key manually.',
replaceTitle: 'Replace this access key?',
replaceDescription:
'After saving successfully, the old key will stop working. Requests already in progress are unaffected. Update every client using this key.',
replaceWeakWarning:
'The new key is weak and easy to guess, which may allow unauthorized requests and quota consumption.',
saveAnyway: 'Save anyway',
editKeyIndeterminate:
'The key replacement result is unknown. Check the original operation before making another change.',
editKeyReconciling:
'The key replacement was committed and is still being applied. Continue checking the result.',
label: 'Access key',
placeholder: 'Leave blank to generate automatically',
automaticHint: 'A random key will be generated automatically.',
show: 'Show key',
hide: 'Hide key',
strength: 'Strength: {level}',
weak: 'Weak',
fair: 'Fair',
strong: 'Stronger',
estimate: 'Estimate only',
weakHint: 'Use a longer key and avoid sequences or repeated characters.',
invalid: 'Use up to 256 visible ASCII characters without spaces or control characters.',
duplicate: 'This access key already exists. Use a different key.',
adminConflict: 'The administrator key cannot be used as an access key.',
warningTitle: 'Use a weak key anyway?',
warningDescription:
'This key is easy to guess and may allow unauthorized requests and quota consumption. Consider changing it or leaving the field blank to generate a random key.',
warningClose: 'Close weak key warning',
returnToEdit: 'Edit key',
createAnyway: 'Create anyway',
},
distribution: {
sort: 'Sort',
expiration: 'Expiration',
@@ -44,7 +82,6 @@ export default {
reset: 'Cost allowances reset for access key “{name}”',
enabled: 'Access key “{name}” enabled',
disabled: 'Access key “{name}” disabled',
rotated: 'Access key “{name}” rotated',
},
loading: 'Loading access keys…',
loadFailed: 'Unable to load access keys.',
@@ -113,10 +150,6 @@ export default {
'The update to access key “{name}” has an unknown outcome. Check current metadata before editing another access key.',
editReconciling:
'The update to access key “{name}” was committed and still needs metadata reconciliation.',
rotateIndeterminate:
'The rotation outcome for access key “{name}” is unknown. Check it using the original operation.',
rotateReconciling:
'The rotation for access key “{name}” was committed and runtime recovery is still in progress.',
checkResult: 'Check result',
},
status: {
@@ -292,28 +325,6 @@ export default {
failed: 'Unable to delete the access key.',
deletedAnnouncement: 'Deleted access key “{name}”.',
},
rotate: {
open: 'Rotate key',
title: 'Rotate this access key now?',
description: 'Generate a new access key for “{name}”.',
close: 'Close access key rotation confirmation',
impact:
'The old key becomes invalid as soon as rotation succeeds. Requests already in progress are unaffected.',
confirm: 'Rotate now',
checkResult: 'Check result',
done: 'Done',
failed: 'Unable to rotate the access key.',
indeterminate: 'The rotation outcome is unknown. Check it using the same operation.',
reconciling:
'The rotation was committed and runtime recovery is still in progress. Check again.',
refreshFailed:
'Rotation completed, but current metadata could not be refreshed. Close and refresh the list.',
newKey: 'New access key',
currentKey: 'Current access key',
newKeyHint: 'The new key is active. Update client configuration now.',
replayedHint:
'This is an idempotent replay. Copy uses the existing Reveal endpoint to read the current key.',
},
reset: {
open: 'Reset allowances',
title: 'Reset cost allowances?',
+38 -26
View File
@@ -1,5 +1,43 @@
export default {
accessKeys: {
customKey: {
generate: 'ランダム生成',
current: '現在のキー:',
editPlaceholder: '新しいキーを入力。空欄の場合は変更しません',
keepHint: '空欄の場合は現在のキーを維持します。',
saveHint: '新しいキーは保存成功後に有効になります。',
generateFailed: '安全なランダムキーを生成できません。再試行するか手動で入力してください。',
replaceTitle: 'アクセスキーを変更しますか?',
replaceDescription:
'保存に成功すると、古いキーは直ちに無効になります。実行中のリクエストには影響しません。このキーを使用するクライアントの設定を更新してください。',
replaceWeakWarning:
'新しいキーは推測されやすく、不正なリクエストや利用枠の消費につながる可能性があります。',
saveAnyway: '保存を続ける',
editKeyIndeterminate:
'キー変更の結果が不明です。再度変更する前に、元の操作の結果を確認してください。',
editKeyReconciling:
'キー変更は確定しましたが、反映処理が続いています。結果の確認を続けてください。',
label: 'アクセスキー',
placeholder: '空欄の場合は自動生成',
automaticHint: 'ランダムなキーを自動生成します。',
show: 'キーを表示',
hide: 'キーを隠す',
strength: '強度:{level}',
weak: '弱い',
fair: '普通',
strong: '比較的強い',
estimate: '目安です',
weakHint: '長さを増やし、連続または繰り返しの文字を避けてください。',
invalid: '空白や制御文字を含まない、256 文字以内の表示可能な ASCII 文字を使用してください。',
duplicate: 'このアクセスキーは既に存在します。別のキーを使用してください。',
adminConflict: '管理者キーをアクセスキーとして使用することはできません。',
warningTitle: '弱いキーを使用しますか?',
warningDescription:
'このキーは推測されやすく、不正なリクエストや利用枠の消費につながる可能性があります。キーを変更するか、空欄にしてランダムなキーを生成してください。',
warningClose: '弱いキーの警告を閉じる',
returnToEdit: '修正する',
createAnyway: '作成を続ける',
},
distribution: {
sort: '並び順',
expiration: '有効期限',
@@ -44,7 +82,6 @@ export default {
reset: 'アクセスキー「{name}」の費用枠をリセットしました',
enabled: 'アクセスキー「{name}」を有効にしました',
disabled: 'アクセスキー「{name}」を無効にしました',
rotated: 'アクセスキー「{name}」をローテーションしました',
},
loading: 'アクセスキーを読み込み中…',
loadFailed: 'アクセスキーを読み込めません。',
@@ -113,10 +150,6 @@ export default {
'アクセスキー「{name}」の更新結果は不明です。別のアクセスキーを編集する前に現在のメタデータを確認してください。',
editReconciling:
'アクセスキー「{name}」の更新は確定しましたが、現在のメタデータを確認する必要があります。',
rotateIndeterminate:
'アクセスキー「{name}」のローテーション結果は不明です。元の操作で結果を確認してください。',
rotateReconciling:
'アクセスキー「{name}」のローテーションは確定しましたが、ランタイムの復旧中です。',
checkResult: '結果を確認',
},
status: {
@@ -288,27 +321,6 @@ export default {
failed: 'アクセスキーを削除できません。',
deletedAnnouncement: 'アクセスキー「{name}」を削除しました。',
},
rotate: {
open: 'キーをローテーション',
title: 'このアクセスキーを今すぐローテーションしますか?',
description: '「{name}」の新しいアクセスキーを生成します。',
close: 'アクセスキーのローテーション確認を閉じる',
impact:
'ローテーション成功後、古いキーは直ちに無効になります。実行中のリクエストには影響しません。',
confirm: '今すぐローテーション',
checkResult: '結果を確認',
done: '完了',
failed: 'アクセスキーをローテーションできません。',
indeterminate: 'ローテーション結果は不明です。同じ操作で結果を確認してください。',
reconciling: 'ローテーションは確定しましたが、ランタイムの復旧中です。再度確認してください。',
refreshFailed:
'ローテーションは完了しましたが、現在のメタデータを更新できません。閉じて一覧を再読み込みしてください。',
newKey: '新しいアクセスキー',
currentKey: '現在のアクセスキー',
newKeyHint: '新しいキーは有効です。クライアント設定をすぐに更新してください。',
replayedHint:
'これは冪等リプレイです。コピー時は既存の Reveal API から現在のキーを取得します。',
},
reset: {
open: '費用枠をリセット',
title: '費用枠をリセットしますか?',
+35 -21
View File
@@ -1,5 +1,40 @@
export default {
accessKeys: {
customKey: {
generate: '随机生成',
current: '当前密钥:',
editPlaceholder: '输入新密钥,留空保持不变',
keepHint: '留空将保持当前密钥。',
saveHint: '新密钥仅在保存成功后生效。',
generateFailed: '无法生成安全的随机密钥,请重试或手动输入。',
replaceTitle: '确认更换访问密钥?',
replaceDescription:
'保存成功后,旧密钥将立即失效;已经开始的请求不受影响。请同步更新使用此密钥的客户端。',
replaceWeakWarning: '新密钥强度较弱,容易被猜测,可能导致未经授权的调用和额度消耗。',
saveAnyway: '仍然保存',
editKeyIndeterminate: '密钥更换结果未知;请核对原操作,避免重复更换。',
editKeyReconciling: '密钥更换已提交,正在恢复生效状态;请继续核对结果。',
label: '访问密钥',
placeholder: '留空由系统自动生成',
automaticHint: '将自动生成随机密钥。',
show: '显示密钥',
hide: '隐藏密钥',
strength: '强度:{level}',
weak: '弱',
fair: '一般',
strong: '较强',
estimate: '仅供参考',
weakHint: '建议增加长度,避免连续或重复字符。',
invalid: '密钥最多 256 个字符,仅支持英文字母、数字和英文符号,不允许空白。',
duplicate: '此访问密钥已存在,请使用其他密钥。',
adminConflict: '不能使用管理员密钥作为访问密钥。',
warningTitle: '仍要使用弱密钥?',
warningDescription:
'该密钥容易被猜测,可能导致未经授权的调用和额度消耗。建议修改密钥,或清空输入以自动生成。',
warningClose: '关闭弱密钥提示',
returnToEdit: '返回修改',
createAnyway: '仍然创建',
},
distribution: {
sort: '排序',
expiration: '有效期',
@@ -44,7 +79,6 @@ export default {
reset: '访问密钥“{name}”的费用额度已重置',
enabled: '访问密钥“{name}”已启用',
disabled: '访问密钥“{name}”已停用',
rotated: '访问密钥“{name}”已轮换',
},
loading: '正在加载访问密钥…',
loadFailed: '无法加载访问密钥。',
@@ -109,8 +143,6 @@ export default {
reconciling: '访问密钥已提交,运行时仍在恢复。',
editIndeterminate: '访问密钥“{name}”的更新结果未知;编辑其他访问密钥前请先核对当前元数据。',
editReconciling: '访问密钥“{name}”的更新已提交,仍需核对当前元数据。',
rotateIndeterminate: '访问密钥“{name}”的轮换结果未知;请使用原操作核对结果。',
rotateReconciling: '访问密钥“{name}”的轮换已提交,运行时仍在恢复。',
checkResult: '核对结果',
},
status: {
@@ -271,24 +303,6 @@ export default {
failed: '无法删除访问密钥。',
deletedAnnouncement: '已删除访问密钥“{name}”。',
},
rotate: {
open: '轮换密钥',
title: '立即轮换此访问密钥?',
description: '为“{name}”生成新的访问密钥。',
close: '关闭访问密钥轮换确认',
impact: '轮换成功后,旧密钥立即失效;已经开始的请求不受影响。',
confirm: '立即轮换',
checkResult: '核对结果',
done: '完成',
failed: '无法轮换访问密钥。',
indeterminate: '轮换结果未知;请使用同一操作核对结果。',
reconciling: '轮换已提交,运行时仍在恢复;请继续核对结果。',
refreshFailed: '轮换已完成,但当前元数据刷新失败;请关闭后刷新列表。',
newKey: '新访问密钥',
currentKey: '当前访问密钥',
newKeyHint: '新密钥已生效,请立即更新客户端配置。',
replayedHint: '这是幂等重放结果;复制时会通过现有 Reveal 接口读取当前密钥。',
},
reset: {
open: '重置额度',
title: '重置费用额度?',