diff --git a/internal/control/access_key_collection.go b/internal/control/access_key_collection.go index 4898017a7..424e06ef0 100644 --- a/internal/control/access_key_collection.go +++ b/internal/control/access_key_collection.go @@ -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, diff --git a/internal/control/access_key_collection_query_test.go b/internal/control/access_key_collection_query_test.go index 1872688e5..ac657b960 100644 --- a/internal/control/access_key_collection_query_test.go +++ b/internal/control/access_key_collection_query_test.go @@ -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, }, }} } diff --git a/internal/control/access_key_custom_test.go b/internal/control/access_key_custom_test.go new file mode 100644 index 000000000..4b4a28a3b --- /dev/null +++ b/internal/control/access_key_custom_test.go @@ -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") + } +} diff --git a/internal/control/access_key_edit_credential_test.go b/internal/control/access_key_edit_credential_test.go new file mode 100644 index 000000000..61e9d24f7 --- /dev/null +++ b/internal/control/access_key_edit_credential_test.go @@ -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") + } +} diff --git a/internal/control/access_key_idempotency.go b/internal/control/access_key_idempotency.go index 04e8991a0..7a3e39bec 100644 --- a/internal/control/access_key_idempotency.go +++ b/internal/control/access_key_idempotency.go @@ -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, diff --git a/internal/control/access_key_mask_test.go b/internal/control/access_key_mask_test.go new file mode 100644 index 000000000..51b3ed1c5 --- /dev/null +++ b/internal/control/access_key_mask_test.go @@ -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") + } + }) + } +} diff --git a/internal/control/access_key_rotation.go b/internal/control/access_key_rotation.go index 098f5bdfd..d7815e361 100644 --- a/internal/control/access_key_rotation.go +++ b/internal/control/access_key_rotation.go @@ -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). diff --git a/internal/control/access_key_update_idempotency.go b/internal/control/access_key_update_idempotency.go new file mode 100644 index 000000000..ceaf90ba6 --- /dev/null +++ b/internal/control/access_key_update_idempotency.go @@ -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 +} diff --git a/internal/control/access_keys.go b/internal/control/access_keys.go index 313832814..1b714ffe8 100644 --- a/internal/control/access_keys.go +++ b/internal/control/access_keys.go @@ -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 } } diff --git a/internal/control/access_keys_phase1_test.go b/internal/control/access_keys_phase1_test.go index c1454a5e5..be430956a 100644 --- a/internal/control/access_keys_phase1_test.go +++ b/internal/control/access_keys_phase1_test.go @@ -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 { diff --git a/internal/control/bootstrap.go b/internal/control/bootstrap.go index 11d379d03..8ae2759eb 100644 --- a/internal/control/bootstrap.go +++ b/internal/control/bootstrap.go @@ -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 } diff --git a/internal/control/health.go b/internal/control/health.go index 379703326..9b239508b 100644 --- a/internal/control/health.go +++ b/internal/control/health.go @@ -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), diff --git a/internal/control/home.go b/internal/control/home.go index d20455cb8..d95e7b0bd 100644 --- a/internal/control/home.go +++ b/internal/control/home.go @@ -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, diff --git a/internal/control/idempotency_digest.go b/internal/control/idempotency_digest.go index e3aa724b9..d16ffaf73 100644 --- a/internal/control/idempotency_digest.go +++ b/internal/control/idempotency_digest.go @@ -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: diff --git a/internal/control/idempotency_operation.go b/internal/control/idempotency_operation.go index 1285498da..bb565c276 100644 --- a/internal/control/idempotency_operation.go +++ b/internal/control/idempotency_operation.go @@ -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, diff --git a/internal/control/operation_recovery.go b/internal/control/operation_recovery.go index bbd96f4b9..74b36a82f 100644 --- a/internal/control/operation_recovery.go +++ b/internal/control/operation_recovery.go @@ -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: diff --git a/internal/control/server.go b/internal/control/server.go index 79d9c7489..4cf1b6511 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -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" } diff --git a/internal/platform/errors/errors.go b/internal/platform/errors/errors.go index 8c1da709e..090240db1 100644 --- a/internal/platform/errors/errors.go +++ b/internal/platform/errors/errors.go @@ -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"} diff --git a/internal/platform/i18n/locales/en-US.go b/internal/platform/i18n/locales/en-US.go index 7a04fa568..fbd49e641 100644 --- a/internal/platform/i18n/locales/en-US.go +++ b/internal/platform/i18n/locales/en-US.go @@ -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", diff --git a/internal/platform/i18n/locales/ja-JP.go b/internal/platform/i18n/locales/ja-JP.go index 3cd392512..27cef37d8 100644 --- a/internal/platform/i18n/locales/ja-JP.go +++ b/internal/platform/i18n/locales/ja-JP.go @@ -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メソッドです", diff --git a/internal/platform/i18n/locales/zh-CN.go b/internal/platform/i18n/locales/zh-CN.go index 20e3dbde2..9dfa77478 100644 --- a/internal/platform/i18n/locales/zh-CN.go +++ b/internal/platform/i18n/locales/zh-CN.go @@ -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": "请求方法不被允许", diff --git a/internal/state/loader/loader.go b/internal/state/loader/loader.go index 6afde932e..9c521b974 100644 --- a/internal/state/loader/loader.go +++ b/internal/state/loader/loader.go @@ -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]...), diff --git a/internal/state/snapshot.go b/internal/state/snapshot.go index e9ec1743d..ffb0ea241 100644 --- a/internal/state/snapshot.go +++ b/internal/state/snapshot.go @@ -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), diff --git a/internal/storage/access_key_prefix_migration_test.go b/internal/storage/access_key_prefix_migration_test.go new file mode 100644 index 000000000..9e32a8bec --- /dev/null +++ b/internal/storage/access_key_prefix_migration_test.go @@ -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") + } + } + }) + } +} diff --git a/internal/storage/custom_access_key_migration_test.go b/internal/storage/custom_access_key_migration_test.go new file mode 100644 index 000000000..856a3552a --- /dev/null +++ b/internal/storage/custom_access_key_migration_test.go @@ -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(¤t, 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(¤tRule, 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") + } + } + }) + } +} diff --git a/internal/storage/database_integration_test.go b/internal/storage/database_integration_test.go index dc4b8eae9..0b31fb24a 100644 --- a/internal/storage/database_integration_test.go +++ b/internal/storage/database_integration_test.go @@ -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") diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0437dcd14..2793a805d 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -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) diff --git a/internal/storage/migration.go b/internal/storage/migration.go index 3b1d121fa..8bb275e8f 100644 --- a/internal/storage/migration.go +++ b/internal/storage/migration.go @@ -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 { diff --git a/internal/storage/migration_sqlite.go b/internal/storage/migration_sqlite.go new file mode 100644 index 000000000..9544d97f7 --- /dev/null +++ b/internal/storage/migration_sqlite.go @@ -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 + }) +} diff --git a/internal/storage/migration_sqlite_test.go b/internal/storage/migration_sqlite_test.go new file mode 100644 index 000000000..704132789 --- /dev/null +++ b/internal/storage/migration_sqlite_test.go @@ -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") + } +} diff --git a/internal/storage/migration_test.go b/internal/storage/migration_test.go index 51ca8894e..abb26bd07 100644 --- a/internal/storage/migration_test.go +++ b/internal/storage/migration_test.go @@ -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)) diff --git a/internal/storage/migrations/0007_access_key_lifecycle_test.go b/internal/storage/migrations/0007_access_key_lifecycle_test.go index 677c28264..1795a1ed6 100644 --- a/internal/storage/migrations/0007_access_key_lifecycle_test.go +++ b/internal/storage/migrations/0007_access_key_lifecycle_test.go @@ -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) } diff --git a/internal/storage/migrations/0011_custom_access_keys.go b/internal/storage/migrations/0011_custom_access_keys.go new file mode 100644 index 000000000..35aeef8db --- /dev/null +++ b/internal/storage/migrations/0011_custom_access_keys.go @@ -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 +} diff --git a/internal/storage/migrations/0012_access_key_mask_prefix.go b/internal/storage/migrations/0012_access_key_mask_prefix.go new file mode 100644 index 000000000..776e042e7 --- /dev/null +++ b/internal/storage/migrations/0012_access_key_mask_prefix.go @@ -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") +} diff --git a/internal/storage/models/access_key.go b/internal/storage/models/access_key.go index b180358e8..ac8452bd9 100644 --- a/internal/storage/models/access_key.go +++ b/internal/storage/models/access_key.go @@ -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"` } diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 9d82ecd92..8c7708259 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -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 diff --git a/web/src/app/mutation-outcome.ts b/web/src/app/mutation-outcome.ts index f88a8a1f5..1846545d8 100644 --- a/web/src/app/mutation-outcome.ts +++ b/web/src/app/mutation-outcome.ts @@ -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 { } 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 { diff --git a/web/src/app/resources/access-keys.ts b/web/src/app/resources/access-keys.ts index 0fd0baa90..1a6461a43 100644 --- a/web/src/app/resources/access-keys.ts +++ b/web/src/app/resources/access-keys.ts @@ -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 { - 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 { return projectAccessKeyMetadata( await client.request(`/api/access-keys/${id}`, { method: 'PUT', + ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}), json: body, signal, }), diff --git a/web/src/app/resources/invalidation.ts b/web/src/app/resources/invalidation.ts index 48e65e78d..f2b4241bf 100644 --- a/web/src/app/resources/invalidation.ts +++ b/web/src/app/resources/invalidation.ts @@ -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], diff --git a/web/src/components/ui/AppConfirmDialog.vue b/web/src/components/ui/AppConfirmDialog.vue index f18372f18..6b63b23b4 100644 --- a/web/src/components/ui/AppConfirmDialog.vue +++ b/web/src/components/ui/AppConfirmDialog.vue @@ -1,8 +1,10 @@