feat(access-keys): 支持自定义密钥并统一编辑与生成交互 (#611)
* feat(access-keys): 支持自定义密钥与强度提示 * fix(access-keys): 恢复默认掩码并按密钥长度分档 * feat(access-keys): 统一密钥编辑与随机生成交互 * style(access-keys): 改用骰子图标生成密钥 * fix(access-keys): 修复筛选幂等判定与恢复状态提示 * fix(access-keys): 统一编辑请求的幂等摘要
This commit is contained in:
1 parent
d417b7de04
commit
de6d2d44fc
55 files changed
+2065
-633
No files matched your search
@@ -53,6 +53,7 @@ type accessKeyCollectionRecord struct {
|
||||
}
|
||||
|
||||
type accessKeyCollectionRow struct {
|
||||
KeyPrefix string
|
||||
PriceMultiplierMicros *int64
|
||||
ID uint
|
||||
Name string
|
||||
@@ -122,7 +123,7 @@ func (s *Service) captureAccessKeyCollectionRecords(
|
||||
if err := s.withReadSnapshot(ctx, func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"access_keys.id", "access_keys.name", "access_keys.key_suffix",
|
||||
"access_keys.id", "access_keys.name", "access_keys.key_prefix", "access_keys.key_suffix",
|
||||
"access_keys.status", "access_keys.filters", "access_keys.rpm_limit",
|
||||
"access_keys.expires_at_ms", "access_keys.price_multiplier_micros",
|
||||
"access_keys.created_at_ms", "access_keys.updated_at_ms",
|
||||
@@ -161,6 +162,7 @@ func (s *Service) captureAccessKeyCollectionRecords(
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
KeySuffix: row.KeySuffix,
|
||||
KeyPrefix: row.KeyPrefix,
|
||||
Status: row.Status,
|
||||
Filters: row.Filters,
|
||||
RPMLimit: row.RPMLimit,
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestListAccessKeyCollectionRejectsCanceledContextAndInvalidMappedMetadata(t
|
||||
}
|
||||
|
||||
row := models.AccessKey{
|
||||
Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "ZZZZ",
|
||||
Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "bad ",
|
||||
Status: string(state.AccessKeyStatusActive), Filters: models.JSON(`{}`),
|
||||
}
|
||||
if err := fixture.db.Exec("PRAGMA ignore_check_constraints = ON").Error; err != nil {
|
||||
@@ -202,7 +202,7 @@ func TestListAccessKeyCollectionRejectsCanceledContextAndInvalidMappedMetadata(t
|
||||
func accessKeyCollectionQueryRecord(id uint, name, suffix string, status state.AccessKeyStatus, updatedAtMS int64) accessKeyCollectionRecord {
|
||||
return accessKeyCollectionRecord{AccessKeyCollectionItem: AccessKeyCollectionItem{
|
||||
AccessKeyMetadata: AccessKeyMetadata{
|
||||
ID: id, Name: name, MaskedKey: maskedAccessKey(suffix), Status: status, UpdatedAtMS: updatedAtMS,
|
||||
ID: id, Name: name, MaskedKey: maskedAccessKey("sk-gl-", suffix), Status: status, UpdatedAtMS: updatedAtMS,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestCustomAccessKeyCreationAndReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
for _, key := range []string{"x", "123456", "imported-key_Z9-", "symbols-!\"\\'~", strings.Repeat("A", 256)} {
|
||||
t.Run(fmt.Sprintf("length-%d", len(key)), func(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
body, err := json.Marshal(map[string]string{"name": "custom", "key": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const operation = "00000000-0000-4000-8000-000000008101"
|
||||
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("custom key creation status = %d", first.Code)
|
||||
}
|
||||
var response struct {
|
||||
Data AccessKeyCreateResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created := response.Data
|
||||
if created.Key != key || created.ID == 0 {
|
||||
t.Fatal("creation did not preserve the custom key")
|
||||
}
|
||||
if len(key) <= 8 && created.MaskedKey != "********" {
|
||||
t.Fatal("short key was not fully masked")
|
||||
}
|
||||
if len(key) > 8 && len(key) <= 16 && created.MaskedKey != "****"+key[len(key)-4:] {
|
||||
t.Fatal("custom key suffix was not preserved")
|
||||
}
|
||||
if len(key) > 16 && created.MaskedKey != key[:6]+"****"+key[len(key)-4:] {
|
||||
t.Fatal("custom key prefix and suffix were not preserved")
|
||||
}
|
||||
row := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
if row.KeyValue == key || row.KeyHash != fixture.encryption.Hash(key) {
|
||||
t.Fatal("custom key storage is not encrypted and hashed")
|
||||
}
|
||||
revealed, err := fixture.service.RevealAccessKey(t.Context(), created.ID)
|
||||
if err != nil || revealed.Key != key {
|
||||
t.Fatal("custom key reveal failed")
|
||||
}
|
||||
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; !ok {
|
||||
t.Fatal("custom key not published")
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+key)
|
||||
login := httptest.NewRecorder()
|
||||
engine.ServeHTTP(login, request)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("custom key login status = %d", login.Code)
|
||||
}
|
||||
replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation)
|
||||
if replay.Code != http.StatusOK {
|
||||
t.Fatalf("replay status = %d", replay.Code)
|
||||
}
|
||||
data := decodeAccessKeyLifecycleData(t, replay)
|
||||
if _, exists := data["key"]; exists {
|
||||
t.Fatal("replay exposed a secret")
|
||||
}
|
||||
assertJSONRawEqual(t, data["replayed"], "true")
|
||||
otherBody, err := json.Marshal(map[string]string{"name": "custom", "key": "different-custom-key"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(otherBody), operation)
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("changed key replay status = %d", conflict.Code)
|
||||
}
|
||||
duplicate := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), "00000000-0000-4000-8000-000000008102")
|
||||
if duplicate.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate key status = %d", duplicate.Code)
|
||||
}
|
||||
var count int64
|
||||
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatal("replay or duplicate created another key")
|
||||
}
|
||||
var operations []models.ControlOperation
|
||||
if err := fixture.db.Find(&operations).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, operation := range operations {
|
||||
var metadata map[string]json.RawMessage
|
||||
if len(operation.CanonicalResult) > 0 {
|
||||
if err := json.Unmarshal(operation.CanonicalResult, &metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := metadata["key"]; exists {
|
||||
t.Fatal("operation metadata persisted the secret")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomAccessKeyEmptyUsesAutomaticGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
const operation = "00000000-0000-4000-8000-000000008201"
|
||||
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic","key":""}`, operation)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("empty key creation status = %d", response.Code)
|
||||
}
|
||||
data := decodeAccessKeyLifecycleData(t, response)
|
||||
var key string
|
||||
if err := json.Unmarshal(data["key"], &key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "sk-gl-") || len(key) != 38 {
|
||||
t.Fatal("empty input did not generate a random key")
|
||||
}
|
||||
replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic"}`, operation)
|
||||
if replay.Code != http.StatusOK {
|
||||
t.Fatalf("omitted key replay status = %d", replay.Code)
|
||||
}
|
||||
assertJSONRawEqual(t, decodeAccessKeyLifecycleData(t, replay)["replayed"], "true")
|
||||
}
|
||||
|
||||
func TestCustomAccessKeyRejectsUnusableValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
for index, key := range []string{" ", "leading ", "two words", "line\nbreak", "tab\tkey", "nul\x00key", "中文", strings.Repeat("x", 257), authTestKey} {
|
||||
body, err := json.Marshal(map[string]string{"name": "invalid", "key": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), fmt.Sprintf("00000000-0000-4000-8000-%012d", index+8200))
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("case %d status = %d", index, response.Code)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCode := "INVALID_CUSTOM_ACCESS_KEY"
|
||||
if key == authTestKey {
|
||||
wantCode = "ACCESS_KEY_ADMIN_CONFLICT"
|
||||
}
|
||||
if envelope.Code != wantCode {
|
||||
t.Fatalf("case %d error code = %s", index, envelope.Code)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatal("invalid input created a key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestEditAccessKeyReplaysNormalizedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
|
||||
Name: "before",
|
||||
CostLimitRules: OptionalAccessKeyCostLimitRules{Set: true, Values: []AccessKeyCostLimitRuleRequest{
|
||||
{Kind: "total", LimitUSD: "10"},
|
||||
{Kind: "periodic", LimitUSD: "2", PeriodSeconds: 3600},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
totalID, periodicID := created.CostLimitRules[0].ID, created.CostLimitRules[1].ID
|
||||
totalRule := fmt.Sprintf(`{"id":%d,"kind":"total","limit_usd":"10"}`, totalID)
|
||||
periodicRule := fmt.Sprintf(`{"id":%d,"kind":"periodic","limit_usd":"2","period_seconds":3600}`, periodicID)
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
|
||||
for index, test := range []struct {
|
||||
name, initial, retry, different string
|
||||
}{
|
||||
{"name", `"name":" client "`, `"name":"client"`, `"name":"other"`},
|
||||
{"default multiplier", `"price_multiplier":"1.0"`, `"price_multiplier":"1"`, `"price_multiplier":"2"`},
|
||||
{"fractional multiplier", `"price_multiplier":"1.50"`, `"price_multiplier":"1.5"`, `"price_multiplier":"1.6"`},
|
||||
{
|
||||
"cost amount",
|
||||
fmt.Sprintf(`"cost_limit_rules":[{"id":%d,"kind":"total","limit_usd":"10.00"},%s]`, totalID, periodicRule),
|
||||
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, totalRule, periodicRule),
|
||||
fmt.Sprintf(`"cost_limit_rules":[{"id":%d,"kind":"total","limit_usd":"11"},%s]`, totalID, periodicRule),
|
||||
},
|
||||
{
|
||||
"cost rule order and identity",
|
||||
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, totalRule, periodicRule),
|
||||
fmt.Sprintf(`"cost_limit_rules":[%s,%s]`, periodicRule, totalRule),
|
||||
fmt.Sprintf(`"cost_limit_rules":[{"kind":"total","limit_usd":"10"},%s]`, periodicRule),
|
||||
},
|
||||
{"empty cost rules", `"cost_limit_rules":[]`, `"cost_limit_rules":[]`, `"cost_limit_rules":[{"kind":"total","limit_usd":"10"}]`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
operationID := fmt.Sprintf("00000000-0000-4000-8000-%012d", 8621+index)
|
||||
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path,
|
||||
`{"key":"replacement-value",`+test.initial+`}`, operationID)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("initial edit status = %d", first.Code)
|
||||
}
|
||||
before := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path,
|
||||
`{"key":"replacement-value",`+test.retry+`}`, operationID)
|
||||
if replayed.Code != http.StatusOK {
|
||||
t.Fatalf("equivalent edit replay status = %d", replayed.Code)
|
||||
}
|
||||
if !reflect.DeepEqual(decodeAccessKeyLifecycleData(t, first), decodeAccessKeyLifecycleData(t, replayed)) {
|
||||
t.Fatal("replay did not return the original result")
|
||||
}
|
||||
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue {
|
||||
t.Fatal("replay rewrote the credential")
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"key":"replacement-value"}`,
|
||||
`{"key":"replacement-value",` + test.different + `}`,
|
||||
} {
|
||||
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("omitted or different mutation status = %d", conflict.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAccessKeyReplaysEquivalentFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
groupIDs := make([]uint, 0, 2)
|
||||
for index := range 2 {
|
||||
group, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
||||
ChannelID: channel.OpenAICompatible, ConnectionType: models.ConnectionTypeAPIKey,
|
||||
Params: json.RawMessage(fmt.Sprintf(`{"base_url":"https://group-%d.example.com/v1"}`, index)),
|
||||
Models: optionalGroupModels{Set: true, Values: []GroupModel{}}, Credentials: "test-credential",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupIDs = append(groupIDs, group.GroupID)
|
||||
}
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "filter-replay"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
|
||||
for index, test := range []struct {
|
||||
name string
|
||||
initial string
|
||||
retry string
|
||||
}{
|
||||
{
|
||||
name: "normalized sets",
|
||||
initial: fmt.Sprintf(`{"key":"replacement-value","filters":{
|
||||
"groups":[%d,%d,%d],"protocols":["anthropic","openai-completions","anthropic"],
|
||||
"models":[" gpt-b ","gpt-a","gpt-a"],
|
||||
"allowed_cidrs":["198.51.100.7/24","192.0.2.1","192.0.2.1/32"]
|
||||
}}`, groupIDs[1], groupIDs[0], groupIDs[1]),
|
||||
retry: fmt.Sprintf(`{"key":"replacement-value","filters":{
|
||||
"groups":[%d,%d],"protocols":["openai-completions","anthropic"],
|
||||
"models":["gpt-a","gpt-b"],"allowed_cidrs":["192.0.2.1/32","198.51.100.0/24"]
|
||||
}}`, groupIDs[0], groupIDs[1]),
|
||||
},
|
||||
{
|
||||
name: "empty sets",
|
||||
initial: `{"key":"replacement-value","filters":{}}`,
|
||||
retry: `{"key":"replacement-value","filters":{"groups":[],"protocols":[],"models":[],"allowed_cidrs":[]}}`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
operationID := fmt.Sprintf("00000000-0000-4000-8000-%012d", 8430+index)
|
||||
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, test.initial, operationID)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("initial edit status = %d", first.Code)
|
||||
}
|
||||
before := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, test.retry, operationID)
|
||||
if replayed.Code != http.StatusOK {
|
||||
t.Fatalf("equivalent filter replay status = %d", replayed.Code)
|
||||
}
|
||||
if !bytes.Equal(decodeAccessKeyLifecycleData(t, first)["filters"], decodeAccessKeyLifecycleData(t, replayed)["filters"]) {
|
||||
t.Fatal("replay did not return the original filter result")
|
||||
}
|
||||
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue || !bytes.Equal(row.Filters, before.Filters) {
|
||||
t.Fatal("replay rewrote the credential or filters")
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"key":"replacement-value"}`,
|
||||
`{"key":"replacement-value","filters":{"models":["different-model"]}}`,
|
||||
} {
|
||||
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("omitted or different filters status = %d", conflict.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAccessKeyReplacesCredentialAtomicallyAndReplays(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
|
||||
Name: "original", Key: "original-credential-value", RPMLimit: OptionalRPMLimit{Set: true, Value: 7},
|
||||
CostLimitRules: OptionalAccessKeyCostLimitRules{Set: true, Values: []AccessKeyCostLimitRuleRequest{{Kind: "total", LimitUSD: "10"}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
ticket, _ := fixture.service.accessQuota.Admit(created.ID, fixture.service.now())
|
||||
fixture.service.accessQuota.Complete(ticket, 1234)
|
||||
quotaBefore := fixture.service.accessQuota.Snapshot(created.ID, fixture.service.now())
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
|
||||
const firstOperation = "00000000-0000-4000-8000-000000008401"
|
||||
const firstBody = `{"name":"changed","key":"x"}`
|
||||
first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, firstBody, firstOperation)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("edit with key status = %d", first.Code)
|
||||
}
|
||||
data := decodeAccessKeyLifecycleData(t, first)
|
||||
assertJSONRawEqual(t, data["id"], fmt.Sprint(created.ID))
|
||||
assertJSONRawEqual(t, data["name"], `"changed"`)
|
||||
assertJSONRawEqual(t, data["masked_key"], `"********"`)
|
||||
if _, exists := data["key"]; exists {
|
||||
t.Fatal("edit metadata exposed plaintext")
|
||||
}
|
||||
row := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
if row.KeyHash != fixture.encryption.Hash("x") || row.KeyValue == before.KeyValue || row.RPMLimit != 7 {
|
||||
t.Fatal("credential update or policy preservation failed")
|
||||
}
|
||||
if _, exists := fixture.manager.Current().AccessKeysByHash[before.KeyHash]; exists {
|
||||
t.Fatal("old credential remains active")
|
||||
}
|
||||
quotaAfter := fixture.service.accessQuota.Snapshot(created.ID, fixture.service.now())
|
||||
if len(quotaAfter.Rules) != 1 || quotaAfter.Rules[0].UsedNanoUSD != quotaBefore.Rules[0].UsedNanoUSD || quotaAfter.Rules[0].ID != quotaBefore.Rules[0].ID {
|
||||
t.Fatal("editing the key reset usage or quota identity")
|
||||
}
|
||||
|
||||
second := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"key":"y"}`, "00000000-0000-4000-8000-000000008402")
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second edit status = %d", second.Code)
|
||||
}
|
||||
secondRow := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
replayed := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, firstBody, firstOperation)
|
||||
if replayed.Code != http.StatusOK {
|
||||
t.Fatalf("replay status = %d", replayed.Code)
|
||||
}
|
||||
if current := loadAccessKeyRow(t, fixture.db, created.ID); current.KeyValue != secondRow.KeyValue {
|
||||
t.Fatal("retry overwrote a later credential change")
|
||||
}
|
||||
conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"name":"changed","key":"z"}`, firstOperation)
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("changed operation body status = %d", conflict.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAccessKeyEmptyPreservesCredentialAndInvalidInputRollsBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "original", Key: "original-key-value"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "other", Key: "duplicate-key-value"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
|
||||
before := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
for _, body := range []string{`{"name":"kept","key":""}`, `{"name":"kept"}`} {
|
||||
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("metadata edit status = %d", response.Code)
|
||||
}
|
||||
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != before.KeyValue || row.KeyHash != before.KeyHash {
|
||||
t.Fatal("empty or omitted key changed the credential")
|
||||
}
|
||||
}
|
||||
for index, test := range []struct {
|
||||
key string
|
||||
status int
|
||||
}{{"duplicate-key-value", 409}, {"two words", 400}, {authTestKey, 400}} {
|
||||
body, err := json.Marshal(map[string]string{"name": "must-rollback", "key": test.key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, string(body), fmt.Sprintf("00000000-0000-4000-8000-%012d", 8410+index))
|
||||
if response.Code != test.status {
|
||||
t.Fatalf("invalid case %d status = %d", index, response.Code)
|
||||
}
|
||||
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.Name != "kept" || row.KeyHash != before.KeyHash {
|
||||
t.Fatal("rejected key change modified metadata")
|
||||
}
|
||||
}
|
||||
missingOperation := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, `{"key":"replacement-value"}`, "")
|
||||
if missingOperation.Code != http.StatusPreconditionRequired {
|
||||
t.Fatal("key change accepted without an operation identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAccessKeyRecoversCommittedCredentialChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "before", Key: "old-credential-value"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
publish := fixture.service.publishSnapshot
|
||||
fail := true
|
||||
fixture.service.publishSnapshot = func(input state.CompileInput) (*state.ConfigSnapshot, error) {
|
||||
if fail {
|
||||
return nil, errors.New("injected publish failure")
|
||||
}
|
||||
return publish(input)
|
||||
}
|
||||
engine := newAccessKeyLifecycleEngine(t, fixture)
|
||||
path := fmt.Sprintf("/api/access-keys/%d", created.ID)
|
||||
const operationID = "00000000-0000-4000-8000-000000008421"
|
||||
const body = `{"name":"after","key":"new-credential-value"}`
|
||||
response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("failed publish status = %d", response.Code)
|
||||
}
|
||||
committed := loadAccessKeyRow(t, fixture.db, created.ID)
|
||||
if committed.KeyHash == before.KeyHash || committed.Name != "after" {
|
||||
t.Fatal("transaction was not committed")
|
||||
}
|
||||
if _, exists := fixture.manager.Current().AccessKeysByHash[before.KeyHash]; !exists {
|
||||
t.Fatal("failed publication replaced the snapshot")
|
||||
}
|
||||
var operation models.ControlOperation
|
||||
if err := fixture.db.Where("idempotency_key = ?", operationID).Take(&operation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation.OperationKind != "access_key_update" || bytes.Contains(operation.CanonicalResult, []byte("new-credential-value")) {
|
||||
t.Fatal("invalid or secret-bearing operation metadata")
|
||||
}
|
||||
fail = false
|
||||
response = serveAccessKeyLifecycleRequest(t, engine, http.MethodPut, path, body, operationID)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("recovery status = %d", response.Code)
|
||||
}
|
||||
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.KeyValue != committed.KeyValue {
|
||||
t.Fatal("recovery wrote the credential twice")
|
||||
}
|
||||
if _, exists := fixture.manager.Current().AccessKeysByHash[committed.KeyHash]; !exists {
|
||||
t.Fatal("recovery did not publish the new credential")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (s *Service) RotateAccessKeyIdempotent(
|
||||
Updates(map[string]any{
|
||||
"key_value": credential.KeyValue,
|
||||
"key_hash": credential.KeyHash,
|
||||
"key_prefix": credential.KeyPrefix,
|
||||
"key_suffix": credential.KeySuffix,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
@@ -150,7 +151,7 @@ func loadAccessKeyMetadataRow(tx *gorm.DB, id uint) (accessKeyMetadataRow, error
|
||||
var row accessKeyMetadataRow
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit",
|
||||
"id", "name", "key_prefix", "key_suffix", "status", "filters", "rpm_limit",
|
||||
"expires_at_ms", "created_at_ms", "updated_at_ms", "price_multiplier_micros",
|
||||
).
|
||||
Where("id = ?", id).
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gpt-load/internal/platform/canonicaljson"
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/state"
|
||||
stateloader "gpt-load/internal/state/loader"
|
||||
)
|
||||
|
||||
// UpdateAccessKeyIdempotent 将密钥与配置更新作为一次操作,重试只恢复原操作。
|
||||
func (s *Service) UpdateAccessKeyIdempotent(ctx context.Context, idempotencyKey string, id uint, request AccessKeyUpdateRequest) (AccessKeyMetadata, error) {
|
||||
if request.Key == "" {
|
||||
return AccessKeyMetadata{}, app_errors.ErrBadRequest
|
||||
}
|
||||
mutate, err := s.accessKeyUpdateMutation(id, request)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
digestRequest := request
|
||||
digestRequest.Key = ""
|
||||
if request.Name != nil {
|
||||
name, err := normalizeAccessKeyName(*request.Name)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
digestRequest.Name = &name
|
||||
}
|
||||
if request.PriceMultiplier.Set {
|
||||
multiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
digestRequest.PriceMultiplier.Value = pricing.FormatPriceMultiplier(multiplier)
|
||||
}
|
||||
if request.CostLimitRules.Set {
|
||||
rules, err := normalizeAccessKeyCostLimitRules(request.CostLimitRules, true)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
digestRequest.CostLimitRules.Values = costLimitRuleRequestsForDigest(rules)
|
||||
// 编辑摘要必须保留规则 ID,区分保留既有规则与创建新规则。
|
||||
for index, rule := range rules {
|
||||
digestRequest.CostLimitRules.Values[index].ID = rule.ID
|
||||
}
|
||||
}
|
||||
if request.Filters != nil {
|
||||
filters, err := normalizeAccessKeyFilters(request.Filters)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
canonicalFilters := canonicalAccessKeyFilterSet(filters)
|
||||
digestRequest.Filters = &AccessKeyFilters{
|
||||
Groups: canonicalFilters.Groups, Protocols: canonicalFilters.Protocols,
|
||||
Models: canonicalFilters.Models, AllowedCIDRs: canonicalFilters.AllowedCIDRs,
|
||||
}
|
||||
}
|
||||
canonicalBody, err := canonicalIdempotencyBody(struct {
|
||||
Request AccessKeyUpdateRequest `json:"request"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
}{Request: digestRequest, KeyHash: s.encryption.Hash(request.Key)})
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, app_errors.ErrInternalServer
|
||||
}
|
||||
identity := fmt.Sprintf("access-key:%d", id)
|
||||
digest, err := buildIdempotencyDigest(idempotencyDigestInput{
|
||||
Version: 1, Method: "PUT", OperationKind: operationKindAccessKeyUpdate,
|
||||
PathTemplate: "/api/access-keys/:id", ResourceLocator: identity,
|
||||
AuthScopeID: idempotencyAuthScopeID, CanonicalBody: canonicalBody,
|
||||
})
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, app_errors.ErrInternalServer
|
||||
}
|
||||
operation, err := s.executeIdempotentOperation(ctx, idempotentOperationInput{
|
||||
IdempotencyKey: idempotencyKey, DigestVersion: 1, RequestDigest: digest.Digest,
|
||||
Kind: operationKindAccessKeyUpdate,
|
||||
Mutate: func(tx *gorm.DB) (idempotentMutationResult, error) {
|
||||
metadata, err := mutate(tx)
|
||||
if err != nil {
|
||||
return idempotentMutationResult{}, err
|
||||
}
|
||||
input, err := stateloader.BuildCompileInputWithProxy(ctx, tx, s.encryption, s.environmentProxy, s.channelRegistry)
|
||||
if err != nil {
|
||||
return idempotentMutationResult{}, err
|
||||
}
|
||||
if _, err := state.Compile(input); err != nil {
|
||||
return idempotentMutationResult{}, err
|
||||
}
|
||||
result, err := canonicaljson.Marshal(metadata)
|
||||
if err != nil {
|
||||
return idempotentMutationResult{}, app_errors.ErrInternalServer
|
||||
}
|
||||
return idempotentMutationResult{ResourceIdentity: identity, CanonicalResult: result}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
var result AccessKeyMetadata
|
||||
if err := json.Unmarshal(operation.CanonicalResult, &result); err != nil {
|
||||
return AccessKeyMetadata{}, app_errors.ErrInternalServer
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
+105
-52
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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メソッドです",
|
||||
|
||||
@@ -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": "请求方法不被允许",
|
||||
|
||||
@@ -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]...),
|
||||
|
||||
@@ -72,6 +72,7 @@ func externalModelName(model ModelConfig) string {
|
||||
}
|
||||
|
||||
type AccessKeyConfig struct {
|
||||
KeyPrefix string
|
||||
PriceMultiplier *pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
@@ -154,6 +155,7 @@ type GroupCatalogView struct {
|
||||
}
|
||||
|
||||
type AccessKeyView struct {
|
||||
KeyPrefix string
|
||||
PriceMultiplier pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
@@ -286,6 +288,7 @@ func newAccessKeyView(input AccessKeyConfig) AccessKeyView {
|
||||
PriceMultiplier: resolvePriceMultiplier(input.PriceMultiplier),
|
||||
ID: input.ID, Name: input.Name, Status: input.Status,
|
||||
KeySuffix: input.KeySuffix,
|
||||
KeyPrefix: input.KeyPrefix,
|
||||
Filters: cloneFilterSet(input.Filters),
|
||||
ExpiresAtMS: cloneAccessKeyExpiry(input.ExpiresAtMS),
|
||||
AllowedPeerCIDRs: cloneAllowedPeerCIDRs(input.AllowedPeerCIDRs),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAccessKeyPrefixMigrationContract(t *testing.T) {
|
||||
testAccessKeyPrefixMigration(t, openInternalMigrationTestDatabase)
|
||||
}
|
||||
|
||||
func TestExternalAccessKeyPrefixMigrationContract(t *testing.T) {
|
||||
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
|
||||
}
|
||||
testAccessKeyPrefixMigration(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) })
|
||||
}
|
||||
|
||||
func testAccessKeyPrefixMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
db := open(t)
|
||||
if scenario != "fresh" {
|
||||
if err := applyMigrationRegistry(db, migrations[:11]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO access_keys (id, name, key_value, key_hash, key_suffix, status, created_at_ms, updated_at_ms) VALUES (1, 'existing', 'cipher', 'legacy-hash', 'abcd', 'active', 1, 1)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if scenario == "interrupted" && len(migrations) > 11 {
|
||||
entry := migrations[11]
|
||||
up := entry.Up
|
||||
entry.Up = func(tx *gorm.DB) error {
|
||||
if err := up(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("interrupt after access key prefix DDL")
|
||||
}
|
||||
registry := append(append([]migration(nil), migrations[:11]...), entry)
|
||||
if err := applyMigrationRegistry(db, registry); err == nil {
|
||||
t.Fatal("interruption succeeded")
|
||||
}
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
|
||||
t.Fatal("access key mask prefix is missing")
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario == "fresh" {
|
||||
if err := db.Exec("INSERT INTO access_keys (id, name, key_value, key_hash, key_suffix, status, created_at_ms, updated_at_ms) VALUES (1, 'default', 'cipher', 'legacy-hash', 'abcd', 'active', 1, 1)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var row struct{ KeyPrefix, KeySuffix, KeyValue string }
|
||||
if err := db.Table("access_keys").Where("id = 1").Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.KeyPrefix != "sk-gl-" || row.KeySuffix != "abcd" || row.KeyValue != "cipher" {
|
||||
t.Fatal("legacy key mask or ciphertext changed")
|
||||
}
|
||||
for _, prefix := range []string{"", "client"} {
|
||||
if err := db.Table("access_keys").Where("id = 1").Update("key_prefix", prefix).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, prefix := range []any{nil, "abc", "1234567"} {
|
||||
if err := db.Table("access_keys").Where("id = 1").Update("key_prefix", prefix).Error; err == nil {
|
||||
t.Fatal("invalid prefix was accepted")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestCustomAccessKeyMigrationContract(t *testing.T) {
|
||||
testCustomAccessKeyMigration(t, openInternalMigrationTestDatabase)
|
||||
}
|
||||
|
||||
func TestExternalCustomAccessKeyMigrationContract(t *testing.T) {
|
||||
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
|
||||
}
|
||||
testCustomAccessKeyMigration(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) })
|
||||
}
|
||||
|
||||
func testCustomAccessKeyMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
db := open(t)
|
||||
var key models.AccessKey
|
||||
var rule models.AccessKeyCostLimitRule
|
||||
var deletedID uint
|
||||
if scenario != "fresh" {
|
||||
if err := applyMigrationRegistry(db, migrations[:10]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key = models.AccessKey{Name: "existing", KeyValue: "encrypted-test-value", KeyHash: "existing-hash", KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`)}
|
||||
if err := db.Omit("KeyPrefix").Create(&key).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule = models.AccessKeyCostLimitRule{AccessKeyID: key.ID, Kind: models.AccessKeyCostLimitKindTotal, LimitNanoUSD: 100, RuleRevision: 1}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := models.AccessKeyCostLimitState{RuleID: rule.ID, RuleRevision: 1, UsedNanoUSD: 17, SnapshotVersion: 1}
|
||||
if err := db.Create(&state).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 删除过的较大 ID 也不能因重建表而被再次分配。
|
||||
deleted := models.AccessKey{Name: "deleted", KeyValue: "cipher", KeyHash: "deleted-hash", KeySuffix: "dead", Status: "active", Filters: models.JSON(`{}`)}
|
||||
if err := db.Omit("KeyPrefix").Create(&deleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deletedID = deleted.ID
|
||||
if err := db.Delete(&deleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if scenario == "interrupted" && len(migrations) > 10 {
|
||||
entry := migrations[10]
|
||||
up := entry.Up
|
||||
entry.Up = func(tx *gorm.DB) error {
|
||||
if err := up(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("interrupt after custom access key DDL")
|
||||
}
|
||||
registry := append(append([]migration(nil), migrations[:10]...), entry)
|
||||
if err := applyMigrationRegistry(db, registry); err == nil {
|
||||
t.Fatal("interruption succeeded")
|
||||
}
|
||||
if db.Dialector.Name() == "sqlite" {
|
||||
var enabled int
|
||||
if err := db.Raw("PRAGMA foreign_keys").Scan(&enabled).Error; err != nil || enabled != 1 {
|
||||
t.Fatal("interrupted migration did not restore foreign keys")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario != "fresh" {
|
||||
var current models.AccessKey
|
||||
if err := db.Take(¤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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
|
||||
migrationfiles.ID0008,
|
||||
migrationfiles.ID0009,
|
||||
migrationfiles.ID0010,
|
||||
migrationfiles.ID0011,
|
||||
migrationfiles.ID0012,
|
||||
}
|
||||
if len(migrations) != len(wantIDs) {
|
||||
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testin
|
||||
Name: "legacy", KeyValue: "ciphertext", KeyHash: "legacy-hash",
|
||||
KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`),
|
||||
}
|
||||
if err := db.Omit("ExpiresAtMS", "PriceMultiplierMicros").Create(&accessKey).Error; err != nil {
|
||||
if err := db.Omit("ExpiresAtMS", "PriceMultiplierMicros", "KeyPrefix").Create(&accessKey).Error; err != nil {
|
||||
t.Fatalf("create legacy access key: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const ID0011 = "0011_custom_access_keys"
|
||||
const accessKeySuffixCheck0011 = "chk_access_key_suffix"
|
||||
const accessKeySuffixExpression0011 = "length(key_suffix) = 4"
|
||||
const priorAccessKeySuffixExpression0011 = "length(key_suffix) = 4 AND substr(key_suffix, 1, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 2, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 3, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 4, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f')"
|
||||
|
||||
// Up0011 保留四位尾号字段,允许自定义字符及短密钥的全遮罩。
|
||||
func Up0011(db *gorm.DB) error {
|
||||
if err := ValidateRecoverable0011(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if Validate0011(db) == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
if err := rebuildAccessKeysSQLite0011(db); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
drop := "DROP CONSTRAINT"
|
||||
if dialector, ok := db.Dialector.(*gormmysql.Dialector); ok && dialector.Config != nil && mysqlRequiresCheckDropSyntax0003(dialector.ServerVersion) {
|
||||
drop = "DROP CHECK"
|
||||
}
|
||||
// MySQL 在同一条 DDL 内原子替换,building 标记可安全恢复。
|
||||
if err := db.Exec("ALTER TABLE access_keys " + drop + " chk_access_key_suffix, ADD CONSTRAINT chk_access_key_suffix CHECK (" + accessKeySuffixExpression0011 + ")").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return Validate0011(db)
|
||||
}
|
||||
|
||||
func ValidateRecoverable0011(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("access_keys") || !db.Migrator().HasColumn("access_keys", "key_suffix") || !db.Migrator().HasConstraint("access_keys", accessKeySuffixCheck0011) {
|
||||
return fmt.Errorf("custom access key suffix schema is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate0011(db *gorm.DB) error {
|
||||
if err := ValidateRecoverable0011(db); err != nil {
|
||||
return err
|
||||
}
|
||||
definition, err := accessKeySuffixConstraint0011(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized := strings.NewReplacer(" ", "", "\n", "", "\t", "", "`", "", `"`, "", "(", "", ")", "", "::text", "").Replace(strings.ToLower(definition))
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
if !strings.Contains(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4,") && !strings.HasSuffix(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4") {
|
||||
return fmt.Errorf("custom access key suffix constraint is invalid")
|
||||
}
|
||||
} else if normalized != "lengthkey_suffix=4" && normalized != "checklengthkey_suffix=4" {
|
||||
return fmt.Errorf("custom access key suffix constraint is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func accessKeySuffixConstraint0011(db *gorm.DB) (string, error) {
|
||||
var definition string
|
||||
var err error
|
||||
switch strings.ToLower(db.Dialector.Name()) {
|
||||
case "sqlite":
|
||||
err = db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_keys'").Scan(&definition).Error
|
||||
case "mysql":
|
||||
err = db.Raw("SELECT CHECK_CLAUSE FROM information_schema.check_constraints WHERE constraint_schema = DATABASE() AND constraint_name = ?", accessKeySuffixCheck0011).Scan(&definition).Error
|
||||
case "postgres", "postgresql":
|
||||
err = db.Raw("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = ? AND conrelid = 'access_keys'::regclass", accessKeySuffixCheck0011).Scan(&definition).Error
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported custom access key migration driver")
|
||||
}
|
||||
return definition, err
|
||||
}
|
||||
|
||||
func rebuildAccessKeysSQLite0011(db *gorm.DB) error {
|
||||
var foreignKeys int
|
||||
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if foreignKeys != 0 {
|
||||
return fmt.Errorf("access key rebuild requires migration foreign key isolation")
|
||||
}
|
||||
ddl, err := accessKeySuffixConstraint0011(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Count(ddl, priorAccessKeySuffixExpression0011) != 1 {
|
||||
return fmt.Errorf("unexpected prior access key suffix constraint")
|
||||
}
|
||||
start := strings.Index(ddl, "(")
|
||||
if start < 0 {
|
||||
return fmt.Errorf("invalid access key table definition")
|
||||
}
|
||||
ddl = "CREATE TABLE access_keys__0011 " + strings.Replace(ddl[start:], priorAccessKeySuffixExpression0011, accessKeySuffixExpression0011, 1)
|
||||
var objects []string
|
||||
if err := db.Raw("SELECT sql FROM sqlite_master WHERE tbl_name = 'access_keys' AND type IN ('index','trigger') AND sql IS NOT NULL ORDER BY type, name").Scan(&objects).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var sequence int64
|
||||
if err := db.Raw("SELECT COALESCE(MAX(seq), 0) FROM sqlite_sequence WHERE name = 'access_keys'").Scan(&sequence).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
columns, err := db.Migrator().ColumnTypes("access_keys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
if strings.ContainsAny(column.Name(), "\"`\x00") {
|
||||
return fmt.Errorf("invalid access key column")
|
||||
}
|
||||
names = append(names, `"`+column.Name()+`"`)
|
||||
}
|
||||
projection := strings.Join(names, ",")
|
||||
for _, statement := range []string{ddl,
|
||||
"INSERT INTO access_keys__0011 (" + projection + ") SELECT " + projection + " FROM access_keys",
|
||||
"DROP TABLE access_keys", "ALTER TABLE access_keys__0011 RENAME TO access_keys"} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Exec("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'access_keys'", sequence).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range objects {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const ID0012 = "0012_access_key_mask_prefix"
|
||||
|
||||
// Up0012 为脱敏前缀保存独立元数据;既有系统生成密钥保留 sk-gl-,列表无需解密。
|
||||
func Up0012(db *gorm.DB) error {
|
||||
if err := ValidateRecoverable0012(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
|
||||
if err := db.Exec("ALTER TABLE access_keys ADD COLUMN key_prefix VARCHAR(6) NOT NULL DEFAULT 'sk-gl-' CONSTRAINT chk_access_key_prefix CHECK (length(key_prefix) IN (0,6))").Error; err != nil {
|
||||
return fmt.Errorf("add access key mask prefix: %w", err)
|
||||
}
|
||||
}
|
||||
return Validate0012(db)
|
||||
}
|
||||
|
||||
// ValidateRecoverable0012 接受原子加列前后的状态,以支持 MySQL 的 DDL 中断恢复。
|
||||
func ValidateRecoverable0012(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("access_keys") {
|
||||
return fmt.Errorf("access key mask table is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
|
||||
return nil
|
||||
}
|
||||
return validateAccessKeyPrefix0012(db)
|
||||
}
|
||||
|
||||
func Validate0012(db *gorm.DB) error {
|
||||
if !db.Migrator().HasColumn("access_keys", "key_prefix") {
|
||||
return fmt.Errorf("access key mask prefix is missing")
|
||||
}
|
||||
return validateAccessKeyPrefix0012(db)
|
||||
}
|
||||
|
||||
func validateAccessKeyPrefix0012(db *gorm.DB) error {
|
||||
if !db.Migrator().HasConstraint("access_keys", "chk_access_key_prefix") {
|
||||
return fmt.Errorf("access key mask prefix constraint is missing")
|
||||
}
|
||||
columns, err := db.Migrator().ColumnTypes("access_keys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, column := range columns {
|
||||
if column.Name() != "key_prefix" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(column.DatabaseTypeName()), "char") {
|
||||
return fmt.Errorf("access key mask prefix must be varchar")
|
||||
}
|
||||
if nullable, known := column.Nullable(); !known || nullable {
|
||||
return fmt.Errorf("access key mask prefix must not be nullable")
|
||||
}
|
||||
if length, known := column.Length(); known && length != 6 {
|
||||
return fmt.Errorf("access key mask prefix length must be six")
|
||||
}
|
||||
value, known := column.DefaultValue()
|
||||
if !known || value != "sk-gl-" && !strings.HasPrefix(value, "'sk-gl-'") {
|
||||
return fmt.Errorf("access key mask prefix default is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("access key mask prefix is missing")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ApiError, InvalidResponseError, NetworkError, RequestCancelledError } from '@/api/errors'
|
||||
|
||||
type OperationKind =
|
||||
'access_key_create' | 'access_key_rotate' | 'group_create' | 'credential_import'
|
||||
| 'access_key_create'
|
||||
| 'access_key_update'
|
||||
| 'access_key_rotate'
|
||||
| 'group_create'
|
||||
| 'credential_import'
|
||||
|
||||
interface IncompleteOperation {
|
||||
operation_id: string
|
||||
@@ -37,9 +41,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isOperationKind(value: unknown): value is OperationKind {
|
||||
return ['access_key_create', 'access_key_rotate', 'group_create', 'credential_import'].includes(
|
||||
String(value),
|
||||
)
|
||||
return [
|
||||
'access_key_create',
|
||||
'access_key_update',
|
||||
'access_key_rotate',
|
||||
'group_create',
|
||||
'credential_import',
|
||||
].includes(String(value))
|
||||
}
|
||||
|
||||
function incompleteOperation(data: unknown): IncompleteOperation | undefined {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
AccessKeyFiltersDto,
|
||||
AccessKeyOptionDto,
|
||||
AccessKeyRevealDto,
|
||||
AccessKeyRotateResultDto,
|
||||
} from '@/api/control/types'
|
||||
import { knownAccessProtocols } from '@/api/control/protocols'
|
||||
import { InvalidResponseError } from '@/api/errors'
|
||||
@@ -48,11 +47,11 @@ export type {
|
||||
AccessKeyFiltersDto,
|
||||
AccessKeyOptionDto,
|
||||
AccessKeyRevealDto,
|
||||
AccessKeyRotateResultDto,
|
||||
AccessProtocol,
|
||||
} from '@/api/control/types'
|
||||
|
||||
export interface CreateAccessKeyRequest {
|
||||
key?: string
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
filters: AccessKeyFiltersDto
|
||||
@@ -70,6 +69,7 @@ export interface AccessKeyCostLimitRuleInput {
|
||||
}
|
||||
|
||||
export type UpdateAccessKeyRequest = Partial<{
|
||||
key: string
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
filters: AccessKeyFiltersDto
|
||||
@@ -490,41 +490,17 @@ export async function revealAccessKey(
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateAccessKey(
|
||||
client: ApiClient,
|
||||
id: number,
|
||||
idempotencyKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AccessKeyRotateResultDto> {
|
||||
const record = projectRecord(
|
||||
await client.request(`/api/access-keys/${id}/rotate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
signal,
|
||||
}),
|
||||
)
|
||||
assertNoSecretLikeFields(record, [...metadataFields, 'key', 'replayed'])
|
||||
const key = record.key === undefined ? undefined : projectString(record.key)
|
||||
if (typeof record.replayed !== 'boolean' || record.replayed === (key !== undefined)) {
|
||||
invalidResponse()
|
||||
}
|
||||
const metadata = Object.fromEntries(metadataFields.map((field) => [field, record[field]]))
|
||||
return {
|
||||
...projectAccessKeyMetadata(metadata),
|
||||
...(key === undefined ? {} : { key }),
|
||||
replayed: record.replayed,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccessKey(
|
||||
client: ApiClient,
|
||||
id: number,
|
||||
body: UpdateAccessKeyRequest,
|
||||
signal?: AbortSignal,
|
||||
idempotencyKey?: string,
|
||||
): Promise<AccessKeyDto> {
|
||||
return projectAccessKeyMetadata(
|
||||
await client.request(`/api/access-keys/${id}`, {
|
||||
method: 'PUT',
|
||||
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
||||
json: body,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppButton from './AppButton.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
@@ -15,6 +17,7 @@ withDefaults(
|
||||
confirmDisabled?: boolean
|
||||
dismissible?: boolean
|
||||
preventCloseAutoFocus?: boolean
|
||||
focusCancel?: boolean
|
||||
appearance?: 'default' | 'ledger'
|
||||
descriptionTone?: 'default' | 'warning'
|
||||
}>(),
|
||||
@@ -24,6 +27,7 @@ withDefaults(
|
||||
confirmDisabled: false,
|
||||
dismissible: true,
|
||||
preventCloseAutoFocus: false,
|
||||
focusCancel: false,
|
||||
appearance: 'default',
|
||||
descriptionTone: 'default',
|
||||
},
|
||||
@@ -32,6 +36,16 @@ const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
const cancelButton = ref<InstanceType<typeof AppButton>>()
|
||||
|
||||
function focusInitialControl(event: Event): void {
|
||||
if (!props.focusCancel) return
|
||||
const button = cancelButton.value?.$el
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
event.preventDefault()
|
||||
button.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,11 +60,13 @@ const emit = defineEmits<{
|
||||
:tone="tone"
|
||||
:description-tone="descriptionTone"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@open-auto-focus="focusInitialControl"
|
||||
>
|
||||
<template v-if="$slots.trigger" #trigger><slot name="trigger" /></template>
|
||||
<template v-if="$slots.default" #body><slot /></template>
|
||||
<template #footer>
|
||||
<AppButton
|
||||
ref="cancelButton"
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
:disabled="pending || !dismissible"
|
||||
|
||||
@@ -31,7 +31,10 @@ const props = withDefaults(
|
||||
descriptionTone: 'default',
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ 'update:open': [open: boolean] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
'open-auto-focus': [event: Event]
|
||||
}>()
|
||||
|
||||
function setOpen(open: boolean): void {
|
||||
if (!open && !props.dismissible) return
|
||||
@@ -53,6 +56,7 @@ function guardDismiss(event: Event): void {
|
||||
class="app-dialog__content"
|
||||
:class="[`app-dialog__content--${appearance}`, `app-dialog__content--${tone}`]"
|
||||
@close-auto-focus="preventCloseAutoFocus && $event.preventDefault()"
|
||||
@open-auto-focus="emit('open-auto-focus', $event)"
|
||||
@escape-key-down="guardDismiss"
|
||||
@interact-outside="guardDismiss"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
type?: 'text' | 'number'
|
||||
type?: 'text' | 'number' | 'password'
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
@@ -87,6 +87,7 @@ defineExpose({ focus })
|
||||
:class="{ 'app-text-input__input--mono': monospace }"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<slot name="trailing" />
|
||||
<IconButton
|
||||
v-if="clearLabel && modelValue"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { Dice5, Eye, EyeOff } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import AppTooltip from '@/components/ui/AppTooltip.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
|
||||
import { estimateAccessKeyStrength, isValidCustomAccessKey } from './access-key-strength'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled: boolean
|
||||
error?: string
|
||||
editing?: boolean
|
||||
currentMask?: string
|
||||
}>(),
|
||||
{ editing: false, currentMask: '', error: '' },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const { t } = useI18n()
|
||||
const input = ref<InstanceType<typeof AppTextInput>>()
|
||||
const visible = ref(false)
|
||||
const generationFailed = ref(false)
|
||||
const invalid = computed(() => !isValidCustomAccessKey(props.modelValue))
|
||||
const strength = computed(() => estimateAccessKeyStrength(props.modelValue))
|
||||
const filledSegments = computed(() => ({ weak: 1, fair: 2, strong: 3 })[strength.value ?? 'weak'])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
generationFailed.value = false
|
||||
if (value === '') visible.value = false
|
||||
},
|
||||
)
|
||||
|
||||
function focus(): void {
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function generateKey(): void {
|
||||
generationFailed.value = false
|
||||
try {
|
||||
const bytes = new Uint8Array(16)
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
const random = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
emit('update:modelValue', `sk-gl-${random}`)
|
||||
} catch {
|
||||
generationFailed.value = true
|
||||
}
|
||||
}
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="access-key-credential">
|
||||
<div class="access-key-credential__heading">
|
||||
<label
|
||||
id="access-key-custom-label"
|
||||
for="access-key-custom-value"
|
||||
class="access-key-credential__label"
|
||||
>
|
||||
{{ t('accessKeys.customKey.label') }}
|
||||
<small>{{ t('accessKeys.drawer.optional') }}</small>
|
||||
</label>
|
||||
</div>
|
||||
<small v-if="editing && currentMask">
|
||||
{{ t('accessKeys.customKey.current') }} <code>{{ currentMask }}</code>
|
||||
</small>
|
||||
<div
|
||||
class="access-key-credential__input-group"
|
||||
:class="{ 'access-key-credential__input-group--invalid': invalid || !!error }"
|
||||
data-input-shell
|
||||
role="group"
|
||||
aria-labelledby="access-key-custom-label"
|
||||
>
|
||||
<AppTextInput
|
||||
id="access-key-custom-value"
|
||||
ref="input"
|
||||
:model-value="modelValue"
|
||||
:label="t('accessKeys.customKey.label')"
|
||||
:type="visible ? 'text' : 'password'"
|
||||
:placeholder="
|
||||
t(editing ? 'accessKeys.customKey.editPlaceholder' : 'accessKeys.customKey.placeholder')
|
||||
"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid || !!error"
|
||||
:spellcheck="false"
|
||||
autocomplete="new-password"
|
||||
autocapitalize="none"
|
||||
described-by="access-key-custom-description"
|
||||
aria-labelledby="access-key-custom-label"
|
||||
appearance="surface"
|
||||
size="compact"
|
||||
monospace
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #trailing>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
:disabled="disabled"
|
||||
:label="t(visible ? 'accessKeys.customKey.hide' : 'accessKeys.customKey.show')"
|
||||
:aria-pressed="visible"
|
||||
@click="visible = !visible"
|
||||
>
|
||||
<EyeOff v-if="visible" :size="15" aria-hidden="true" />
|
||||
<Eye v-else :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</AppTextInput>
|
||||
<AppTooltip :content="t('accessKeys.customKey.generate')" :disabled="disabled">
|
||||
<IconButton
|
||||
class="access-key-credential__generate"
|
||||
size="compact"
|
||||
:disabled="disabled"
|
||||
:label="t('accessKeys.customKey.generate')"
|
||||
@click="generateKey"
|
||||
>
|
||||
<Dice5 :size="16" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</AppTooltip>
|
||||
</div>
|
||||
<div
|
||||
id="access-key-custom-description"
|
||||
class="access-key-credential__description"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p v-if="generationFailed" class="access-key-credential__error">
|
||||
{{ t('accessKeys.customKey.generateFailed') }}
|
||||
</p>
|
||||
<p v-if="invalid || error" class="access-key-credential__error">
|
||||
{{ error || t('accessKeys.customKey.invalid') }}
|
||||
</p>
|
||||
<template v-else-if="strength">
|
||||
<div
|
||||
class="access-key-credential__strength"
|
||||
:class="`access-key-credential__strength--${strength}`"
|
||||
>
|
||||
<span class="access-key-credential__segments" aria-hidden="true">
|
||||
<span
|
||||
v-for="segment in 3"
|
||||
:key="segment"
|
||||
:class="{ filled: segment <= filledSegments }"
|
||||
/>
|
||||
</span>
|
||||
<span>{{
|
||||
t('accessKeys.customKey.strength', { level: t(`accessKeys.customKey.${strength}`) })
|
||||
}}</span>
|
||||
<small>{{ t('accessKeys.customKey.estimate') }}</small>
|
||||
</div>
|
||||
<p v-if="strength === 'weak'">{{ t('accessKeys.customKey.weakHint') }}</p>
|
||||
<p>{{ t('accessKeys.customKey.saveHint') }}</p>
|
||||
</template>
|
||||
<p v-else>
|
||||
{{ t(editing ? 'accessKeys.customKey.keepHint' : 'accessKeys.customKey.automaticHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.access-key-credential {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.access-key-credential__heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.access-key-credential__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
}
|
||||
.access-key-credential__input-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.access-key-credential__input-group--invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.access-key-credential__input-group :deep(.app-text-input) {
|
||||
border: 0;
|
||||
border-radius: var(--radius-control) 0 0 var(--radius-control);
|
||||
}
|
||||
.access-key-credential__input-group :deep(.app-text-input[data-input-shell]:focus-within) {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.access-key-credential__generate {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
border-radius: 0 var(--radius-control) var(--radius-control) 0;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.access-key-credential__generate {
|
||||
width: var(--touch-target);
|
||||
}
|
||||
}
|
||||
.access-key-credential small {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
.access-key-credential__label small {
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.access-key-credential__description {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
.access-key-credential__description p {
|
||||
margin: 0;
|
||||
}
|
||||
.access-key-credential__description .access-key-credential__error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.access-key-credential__strength {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.access-key-credential__strength--weak {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.access-key-credential__strength--fair {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.access-key-credential__strength--strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.access-key-credential__strength + p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.access-key-credential__segments {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
width: 60px;
|
||||
}
|
||||
.access-key-credential__segments span {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
.access-key-credential__segments .filled {
|
||||
background: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
} from '@/app/resources/access-keys'
|
||||
import type { AccessKeyDto, AccessProtocol, GroupOptionDto } from '@/api/control/types'
|
||||
import type { ChannelDto } from '@/app/resources/channels'
|
||||
import { RequestCancelledError } from '@/api/errors'
|
||||
import { ApiError, RequestCancelledError } from '@/api/errors'
|
||||
import { classifyMutationOutcome } from '@/app/mutation-outcome'
|
||||
import { applyInvalidationPlan, mutationInvalidationPlans } from '@/app/resources/invalidation'
|
||||
import { useUnsavedChanges } from '@/app/unsaved-changes'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue'
|
||||
import AppDrawer from '@/components/ui/AppDrawer.vue'
|
||||
import type { SearchableMultiSelectOption } from '@/components/ui/SearchableMultiSelect.vue'
|
||||
import { createUUID } from '@/lib/uuid'
|
||||
@@ -35,13 +36,13 @@ import {
|
||||
findAccessKeyForReconciliation,
|
||||
type PendingAccessKeyEditOperation,
|
||||
} from './access-key-edit-operation'
|
||||
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
|
||||
import AccessKeyDeleteDialog from './AccessKeyDeleteDialog.vue'
|
||||
import AccessKeyCostLimitEditor from './AccessKeyCostLimitEditor.vue'
|
||||
import AccessKeyFormFields from './AccessKeyFormFields.vue'
|
||||
import AccessKeyCredentialField from './AccessKeyCredentialField.vue'
|
||||
import { estimateAccessKeyStrength, isValidCustomAccessKey } from './access-key-strength'
|
||||
import AccessKeyOperationFeedback from './AccessKeyOperationFeedback.vue'
|
||||
import AccessKeyPolicyFields from './AccessKeyPolicyFields.vue'
|
||||
import AccessKeyRotateDialog from './AccessKeyRotateDialog.vue'
|
||||
import AccessKeyScopeEditor from './AccessKeyScopeEditor.vue'
|
||||
import {
|
||||
materializeAccessKeyFilters,
|
||||
@@ -74,13 +75,11 @@ const props = withDefaults(
|
||||
groupCatalogState?: GroupCatalogState
|
||||
createOperation?: PendingAccessKeyCreateOperation | null
|
||||
editOperation?: PendingAccessKeyEditOperation | null
|
||||
rotateOperation?: PendingAccessKeyRotateOperation | null
|
||||
}>(),
|
||||
{
|
||||
copyFrom: null,
|
||||
createOperation: null,
|
||||
editOperation: null,
|
||||
rotateOperation: null,
|
||||
groupCatalogState: 'ready',
|
||||
},
|
||||
)
|
||||
@@ -88,9 +87,7 @@ const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
'update:createOperation': [operation: PendingAccessKeyCreateOperation | null]
|
||||
'update:editOperation': [operation: PendingAccessKeyEditOperation | null]
|
||||
'update:rotateOperation': [operation: PendingAccessKeyRotateOperation | null]
|
||||
saved: [kind: 'created' | 'updated', name: string, accessKey?: AccessKeyDto]
|
||||
rotated: [name: string]
|
||||
deleted: [name: string]
|
||||
}>()
|
||||
const client = useApiClient()
|
||||
@@ -101,10 +98,12 @@ const base = ref<AccessKeyDto | null>(null)
|
||||
const draft = ref<AccessKeyDraft>(createAccessKeyDraft())
|
||||
const operationID = ref('')
|
||||
const createPayload = ref<CreateAccessKeyRequest | null>(null)
|
||||
const credentialField = ref<InstanceType<typeof AccessKeyCredentialField>>()
|
||||
const keyConfirmationOpen = ref(false)
|
||||
const customKeyError = ref('')
|
||||
const createOperationRetained = ref(false)
|
||||
const editOperationRetained = ref(false)
|
||||
const pending = ref(false)
|
||||
const rotationPending = ref(false)
|
||||
const failed = ref(false)
|
||||
const mutationState = ref<'idle' | 'indeterminate' | 'reconciling'>('idle')
|
||||
const editReconciliation = ref<PendingAccessKeyEditOperation | null>(null)
|
||||
@@ -113,6 +112,22 @@ const modelInput = ref('')
|
||||
let controller: AbortController | undefined
|
||||
|
||||
const editing = computed(() => base.value !== null)
|
||||
const keyConfirmationDescription = computed(() => {
|
||||
if (!editing.value) return t('accessKeys.customKey.warningDescription')
|
||||
const impact = t('accessKeys.customKey.replaceDescription')
|
||||
return estimateAccessKeyStrength(draft.value.key) === 'weak'
|
||||
? `${impact} ${t('accessKeys.customKey.replaceWeakWarning')}`
|
||||
: impact
|
||||
})
|
||||
const keyConfirmationLabel = computed(() =>
|
||||
t(
|
||||
editing.value
|
||||
? estimateAccessKeyStrength(draft.value.key) === 'weak'
|
||||
? 'accessKeys.customKey.saveAnyway'
|
||||
: 'accessKeys.drawer.saveChanges'
|
||||
: 'accessKeys.customKey.createAnyway',
|
||||
),
|
||||
)
|
||||
const createOperationActive = computed(
|
||||
() =>
|
||||
!editing.value &&
|
||||
@@ -120,14 +135,9 @@ const createOperationActive = computed(
|
||||
(mutationState.value !== 'idle' || failed.value),
|
||||
)
|
||||
const formLocked = computed(
|
||||
() =>
|
||||
pending.value ||
|
||||
rotationPending.value ||
|
||||
createOperationActive.value ||
|
||||
editReconciliation.value !== null ||
|
||||
props.rotateOperation !== null,
|
||||
() => pending.value || createOperationActive.value || editReconciliation.value !== null,
|
||||
)
|
||||
const closeBlocked = computed(() => pending.value || rotationPending.value)
|
||||
const closeBlocked = computed(() => pending.value)
|
||||
const protocolOptions = computed(() => accessKeyProtocolOptions())
|
||||
const selectedGroupIDs = computed(() =>
|
||||
draft.value.scopeModes.groups === 'restricted' ? draft.value.filters.groups : [],
|
||||
@@ -166,14 +176,6 @@ const modelMismatch = computed(
|
||||
draft.value.filters.models.some((model) => !catalogModelOptions.value.includes(model)),
|
||||
)
|
||||
const dirty = computed(() => isAccessKeyDraftDirty(draft.value, base.value))
|
||||
const rotateActionDisabled = computed(
|
||||
() =>
|
||||
pending.value ||
|
||||
rotationPending.value ||
|
||||
createOperationActive.value ||
|
||||
editReconciliation.value !== null ||
|
||||
dirty.value,
|
||||
)
|
||||
const unsavedDirty = computed(
|
||||
() =>
|
||||
dirty.value &&
|
||||
@@ -195,11 +197,17 @@ const scopeValid = computed(() =>
|
||||
const valid = computed(
|
||||
() =>
|
||||
isAccessKeyDraftValid(draft.value, base.value, groupCatalog.value) &&
|
||||
isValidCustomAccessKey(draft.value.key) &&
|
||||
!groupProtocolMismatch.value,
|
||||
)
|
||||
const mutationFeedbackKey = computed(() => {
|
||||
if (mutationState.value === 'idle') return ''
|
||||
if (editReconciliation.value) {
|
||||
if (editReconciliation.value.idempotencyKey) {
|
||||
return mutationState.value === 'reconciling'
|
||||
? 'accessKeys.customKey.editKeyReconciling'
|
||||
: 'accessKeys.customKey.editKeyIndeterminate'
|
||||
}
|
||||
return mutationState.value === 'reconciling'
|
||||
? 'accessKeys.drawer.editReconciling'
|
||||
: 'accessKeys.drawer.editIndeterminate'
|
||||
@@ -246,6 +254,7 @@ const saveBlockerKey = computed(() => {
|
||||
if (pending.value) return 'accessKeys.drawer.saveBlockedPending'
|
||||
if (editReconciliation.value || createOperationActive.value) return ''
|
||||
if (draft.value.name.trim().length === 0) return 'accessKeys.drawer.saveBlockedName'
|
||||
if (!isValidCustomAccessKey(draft.value.key)) return 'accessKeys.customKey.invalid'
|
||||
if (!Number.isSafeInteger(draft.value.rpm_limit) || draft.value.rpm_limit < 0) {
|
||||
return 'accessKeys.drawer.saveBlockedRPM'
|
||||
}
|
||||
@@ -288,10 +297,11 @@ function clearLocalState(): void {
|
||||
draft.value = createAccessKeyDraft()
|
||||
operationID.value = ''
|
||||
createPayload.value = null
|
||||
keyConfirmationOpen.value = false
|
||||
customKeyError.value = ''
|
||||
createOperationRetained.value = false
|
||||
editOperationRetained.value = false
|
||||
pending.value = false
|
||||
rotationPending.value = false
|
||||
failed.value = false
|
||||
mutationState.value = 'idle'
|
||||
editReconciliation.value = null
|
||||
@@ -300,6 +310,8 @@ function clearLocalState(): void {
|
||||
}
|
||||
|
||||
async function resetForOpen(): Promise<void> {
|
||||
keyConfirmationOpen.value = false
|
||||
customKeyError.value = ''
|
||||
const carriedCreateOperation = props.accessKey ? null : props.createOperation
|
||||
const carriedEditOperation =
|
||||
props.accessKey && props.editOperation?.base.id === props.accessKey.id
|
||||
@@ -325,7 +337,7 @@ async function resetForOpen(): Promise<void> {
|
||||
}))
|
||||
}
|
||||
operationID.value = props.accessKey
|
||||
? ''
|
||||
? (carriedEditOperation?.idempotencyKey ?? createUUID())
|
||||
: (carriedCreateOperation?.idempotencyKey ?? createUUID())
|
||||
createPayload.value = carriedCreateOperation
|
||||
? cloneAccessKeyCreatePayload(carriedCreateOperation.payload)
|
||||
@@ -354,12 +366,6 @@ function handleDeleted(name: string): void {
|
||||
emit('deleted', name)
|
||||
}
|
||||
|
||||
function handleRotated(accessKey: AccessKeyDto): void {
|
||||
base.value = accessKey
|
||||
draft.value = createAccessKeyDraft(accessKey)
|
||||
emit('rotated', accessKey.name)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.accessKey, props.copyFrom?.id] as const,
|
||||
([open]) => {
|
||||
@@ -449,6 +455,49 @@ function setScopeMode(dimension: AccessKeyScopeDimension, nextMode: AccessKeySco
|
||||
draft.value.scopeModes[dimension] = nextMode
|
||||
}
|
||||
|
||||
function updateCustomKey(value: string): void {
|
||||
draft.value.key = value
|
||||
customKeyError.value = ''
|
||||
}
|
||||
|
||||
function reportCustomKeyError(error: unknown): void {
|
||||
if (!(error instanceof ApiError)) return
|
||||
if (error.code === 'DUPLICATE_RESOURCE')
|
||||
customKeyError.value = t('accessKeys.customKey.duplicate')
|
||||
if (error.code === 'ACCESS_KEY_ADMIN_CONFLICT')
|
||||
customKeyError.value = t('accessKeys.customKey.adminConflict')
|
||||
if (error.code === 'INVALID_CUSTOM_ACCESS_KEY')
|
||||
customKeyError.value = t('accessKeys.customKey.invalid')
|
||||
}
|
||||
|
||||
async function requestSave(): Promise<void> {
|
||||
if (pending.value || keyConfirmationOpen.value) return
|
||||
if (
|
||||
!createOperationActive.value &&
|
||||
!editReconciliation.value &&
|
||||
valid.value &&
|
||||
dirty.value &&
|
||||
(editing.value ? draft.value.key !== '' : estimateAccessKeyStrength(draft.value.key) === 'weak')
|
||||
) {
|
||||
keyConfirmationOpen.value = true
|
||||
return
|
||||
}
|
||||
await save()
|
||||
}
|
||||
|
||||
async function setKeyConfirmationOpen(open: boolean): Promise<void> {
|
||||
keyConfirmationOpen.value = open
|
||||
if (!open) {
|
||||
await nextTick()
|
||||
credentialField.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmKeyChange(): Promise<void> {
|
||||
keyConfirmationOpen.value = false
|
||||
await save()
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (pending.value) {
|
||||
return
|
||||
@@ -469,6 +518,7 @@ async function save(): Promise<void> {
|
||||
createPayload.value = cloneAccessKeyCreatePayload(activeCreatePayload)
|
||||
}
|
||||
pending.value = true
|
||||
customKeyError.value = ''
|
||||
failed.value = false
|
||||
editNotApplied.value = false
|
||||
mutationState.value = 'idle'
|
||||
@@ -486,6 +536,7 @@ async function save(): Promise<void> {
|
||||
currentBase.id,
|
||||
updateBody!,
|
||||
activeController.signal,
|
||||
updateBody?.key ? activeOperationID : undefined,
|
||||
)
|
||||
if (
|
||||
controller !== activeController ||
|
||||
@@ -534,12 +585,16 @@ async function save(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (error instanceof RequestCancelledError) return
|
||||
if (updateBody?.key || activeCreatePayload?.key) reportCustomKeyError(error)
|
||||
const outcome = classifyMutationOutcome({
|
||||
kind: 'error',
|
||||
error,
|
||||
requestSent: true,
|
||||
})
|
||||
failed.value = outcome.kind === 'failed'
|
||||
if (currentBase && outcome.kind === 'failed' && outcome.reason === 'rejected') {
|
||||
operationID.value = createUUID()
|
||||
}
|
||||
if (!currentBase && outcome.kind === 'failed' && outcome.reason === 'rejected') {
|
||||
operationID.value = createUUID()
|
||||
createPayload.value = null
|
||||
@@ -559,23 +614,30 @@ async function save(): Promise<void> {
|
||||
} else if (
|
||||
currentBase &&
|
||||
updateBody &&
|
||||
(outcome.kind === 'indeterminate' || outcome.kind === 'reconciling')
|
||||
(outcome.kind === 'indeterminate' ||
|
||||
outcome.kind === 'reconciling' ||
|
||||
(!!updateBody.key &&
|
||||
outcome.kind === 'failed' &&
|
||||
outcome.reason === 'retryable-precondition'))
|
||||
) {
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
base: currentBase,
|
||||
patch: updateBody,
|
||||
state: outcome.kind,
|
||||
...(updateBody.key ? { idempotencyKey: activeOperationID } : {}),
|
||||
state: outcome.kind === 'failed' ? 'reconciling' : outcome.kind,
|
||||
}
|
||||
failed.value = false
|
||||
editReconciliation.value = operation
|
||||
editOperationRetained.value = true
|
||||
emit('update:editOperation', operation)
|
||||
}
|
||||
mutationState.value =
|
||||
outcome.kind === 'reconciling'
|
||||
editReconciliation.value?.state ??
|
||||
(outcome.kind === 'reconciling'
|
||||
? 'reconciling'
|
||||
: outcome.kind === 'indeterminate'
|
||||
? 'indeterminate'
|
||||
: 'idle'
|
||||
: 'idle')
|
||||
} finally {
|
||||
if (controller === activeController) {
|
||||
controller = undefined
|
||||
@@ -596,11 +658,38 @@ async function reconcileEdit(): Promise<void> {
|
||||
const activeController = controller
|
||||
let confirmedName: string | null = null
|
||||
try {
|
||||
const latest = await findAccessKeyForReconciliation(
|
||||
client,
|
||||
attempt.base.id,
|
||||
activeController.signal,
|
||||
)
|
||||
let latest: AccessKeyDto | undefined
|
||||
if (attempt.idempotencyKey) {
|
||||
try {
|
||||
latest = await updateAccessKey(
|
||||
client,
|
||||
attempt.base.id,
|
||||
attempt.patch,
|
||||
activeController.signal,
|
||||
attempt.idempotencyKey,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
|
||||
if (
|
||||
outcome.kind !== 'failed' ||
|
||||
outcome.reason !== 'expired-known' ||
|
||||
outcome.resource_identity !== `access-key:${attempt.base.id}`
|
||||
)
|
||||
throw error
|
||||
// 完成记录已过期时只读取当前状态,不能重新执行旧的密钥替换。
|
||||
latest = await findAccessKeyForReconciliation(
|
||||
client,
|
||||
attempt.base.id,
|
||||
activeController.signal,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
latest = await findAccessKeyForReconciliation(
|
||||
client,
|
||||
attempt.base.id,
|
||||
activeController.signal,
|
||||
)
|
||||
}
|
||||
if (controller !== activeController || editReconciliation.value !== attempt || !props.open) {
|
||||
return
|
||||
}
|
||||
@@ -620,7 +709,10 @@ async function reconcileEdit(): Promise<void> {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
if (accessKeyMatchesUpdatePatch(latest, attempt.patch, attempt.base)) {
|
||||
if (
|
||||
attempt.idempotencyKey ||
|
||||
accessKeyMatchesUpdatePatch(latest, attempt.patch, attempt.base)
|
||||
) {
|
||||
base.value = latest
|
||||
draft.value = createAccessKeyDraft(latest)
|
||||
editReconciliation.value = null
|
||||
@@ -660,13 +752,28 @@ async function reconcileEdit(): Promise<void> {
|
||||
editReconciliation.value === attempt &&
|
||||
!(error instanceof RequestCancelledError)
|
||||
) {
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
...attempt,
|
||||
state: 'indeterminate',
|
||||
if (attempt.patch.key) reportCustomKeyError(error)
|
||||
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
|
||||
if (attempt.idempotencyKey && outcome.kind === 'failed' && outcome.reason === 'rejected') {
|
||||
editReconciliation.value = null
|
||||
editOperationRetained.value = false
|
||||
emit('update:editOperation', null)
|
||||
operationID.value = createUUID()
|
||||
mutationState.value = 'idle'
|
||||
failed.value = true
|
||||
} else {
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
...attempt,
|
||||
state:
|
||||
outcome.kind === 'reconciling' ||
|
||||
(outcome.kind === 'failed' && outcome.reason === 'retryable-precondition')
|
||||
? 'reconciling'
|
||||
: 'indeterminate',
|
||||
}
|
||||
editReconciliation.value = operation
|
||||
emit('update:editOperation', operation)
|
||||
mutationState.value = operation.state
|
||||
}
|
||||
editReconciliation.value = operation
|
||||
emit('update:editOperation', operation)
|
||||
mutationState.value = operation.state
|
||||
}
|
||||
} finally {
|
||||
if (controller === activeController) {
|
||||
@@ -694,7 +801,7 @@ onBeforeUnmount(clearLocalState)
|
||||
>
|
||||
<template #trigger><slot name="trigger" /></template>
|
||||
|
||||
<form id="access-key-drawer-form" class="access-key-drawer" @submit.prevent="save">
|
||||
<form id="access-key-drawer-form" class="access-key-drawer" @submit.prevent="requestSave">
|
||||
<AccessKeyOperationFeedback
|
||||
:failed="failed"
|
||||
:edit-not-applied="editNotApplied"
|
||||
@@ -715,7 +822,19 @@ onBeforeUnmount(clearLocalState)
|
||||
@update:status="draft.status = $event"
|
||||
@update:rpm-limit="draft.rpm_limit = $event"
|
||||
@update:price-multiplier="draft.price_multiplier = $event"
|
||||
/>
|
||||
>
|
||||
<template #credential>
|
||||
<AccessKeyCredentialField
|
||||
ref="credentialField"
|
||||
:model-value="draft.key"
|
||||
:editing="editing"
|
||||
:current-mask="base?.masked_key"
|
||||
:disabled="formLocked"
|
||||
:error="customKeyError"
|
||||
@update:model-value="updateCustomKey"
|
||||
/>
|
||||
</template>
|
||||
</AccessKeyFormFields>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
@@ -781,14 +900,6 @@ onBeforeUnmount(clearLocalState)
|
||||
|
||||
<template #footer>
|
||||
<div v-if="editing && base" class="access-key-drawer__management">
|
||||
<AccessKeyRotateDialog
|
||||
:access-key="base"
|
||||
:disabled="rotateActionDisabled"
|
||||
:operation="rotateOperation"
|
||||
@update:pending="rotationPending = $event"
|
||||
@update:operation="emit('update:rotateOperation', $event)"
|
||||
@rotated="handleRotated"
|
||||
/>
|
||||
<AccessKeyDeleteDialog
|
||||
:access-key="base"
|
||||
:total="total"
|
||||
@@ -833,6 +944,19 @@ onBeforeUnmount(clearLocalState)
|
||||
</AppButton>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
<AppConfirmDialog
|
||||
:open="keyConfirmationOpen"
|
||||
:title="t(editing ? 'accessKeys.customKey.replaceTitle' : 'accessKeys.customKey.warningTitle')"
|
||||
:description="keyConfirmationDescription"
|
||||
:close-label="t('accessKeys.customKey.warningClose')"
|
||||
:cancel-label="t('accessKeys.customKey.returnToEdit')"
|
||||
:confirm-label="keyConfirmationLabel"
|
||||
description-tone="warning"
|
||||
focus-cancel
|
||||
prevent-close-auto-focus
|
||||
@update:open="setKeyConfirmationOpen"
|
||||
@confirm="confirmKeyChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -57,6 +57,8 @@ defineExpose({ focusName })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<slot name="credential" />
|
||||
|
||||
<div class="access-key-drawer__field">
|
||||
<span class="access-key-drawer__field-label" aria-hidden="true">
|
||||
{{ t('common.priceMultiplier.label') }}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useApiClient } from '@/api/client-context'
|
||||
import type { AccessKeyDto, AccessKeyRotateResultDto } from '@/api/control/types'
|
||||
import { RequestCancelledError } from '@/api/errors'
|
||||
import { classifyMutationOutcome } from '@/app/mutation-outcome'
|
||||
import { accessKeyResources, revealAccessKey, rotateAccessKey } from '@/app/resources/access-keys'
|
||||
import { applyInvalidationPlan, mutationInvalidationPlans } from '@/app/resources/invalidation'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue'
|
||||
import CopyChip from '@/components/ui/CopyChip.vue'
|
||||
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
import { createUUID } from '@/lib/uuid'
|
||||
|
||||
import { findAccessKeyForReconciliation } from './access-key-edit-operation'
|
||||
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accessKey: AccessKeyDto
|
||||
disabled?: boolean
|
||||
operation?: PendingAccessKeyRotateOperation | null
|
||||
}>(),
|
||||
{ disabled: false, operation: null },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
rotated: [accessKey: AccessKeyDto]
|
||||
'update:pending': [pending: boolean]
|
||||
'update:operation': [operation: PendingAccessKeyRotateOperation | null]
|
||||
}>()
|
||||
const client = useApiClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useI18n()
|
||||
const open = ref(false)
|
||||
const pending = ref(false)
|
||||
const failed = ref(false)
|
||||
const refreshFailed = ref(false)
|
||||
const operationState = ref<'idle' | 'indeterminate' | 'reconciling'>(
|
||||
props.operation?.state ?? 'idle',
|
||||
)
|
||||
const operationID = ref(props.operation?.idempotencyKey ?? createUUID())
|
||||
const result = ref<AccessKeyRotateResultDto | null>(null)
|
||||
const current = ref<AccessKeyDto | null>(null)
|
||||
let controller: AbortController | undefined
|
||||
|
||||
const feedbackKey = computed(() => {
|
||||
if (operationState.value === 'reconciling') return 'accessKeys.rotate.reconciling'
|
||||
if (operationState.value === 'indeterminate') return 'accessKeys.rotate.indeterminate'
|
||||
return ''
|
||||
})
|
||||
const confirmLabel = computed(() => {
|
||||
if (result.value) return t('accessKeys.rotate.done')
|
||||
if (operationState.value !== 'idle') return t('accessKeys.rotate.checkResult')
|
||||
return t('accessKeys.rotate.confirm')
|
||||
})
|
||||
const triggerLabel = computed(() =>
|
||||
operationState.value === 'idle'
|
||||
? t('accessKeys.rotate.open')
|
||||
: t('accessKeys.rotate.checkResult'),
|
||||
)
|
||||
const displayKey = computed(() =>
|
||||
result.value?.key ? result.value.key : (current.value?.masked_key ?? props.accessKey.masked_key),
|
||||
)
|
||||
|
||||
watch(pending, (value) => emit('update:pending', value))
|
||||
watch(
|
||||
() => props.operation,
|
||||
(operation) => {
|
||||
if (pending.value || result.value) return
|
||||
if (operation) {
|
||||
operationID.value = operation.idempotencyKey
|
||||
operationState.value = operation.state
|
||||
} else if (operationState.value !== 'idle') {
|
||||
operationID.value = createUUID()
|
||||
operationState.value = 'idle'
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function resetOperation(): void {
|
||||
controller?.abort()
|
||||
controller = undefined
|
||||
pending.value = false
|
||||
failed.value = false
|
||||
refreshFailed.value = false
|
||||
operationState.value = 'idle'
|
||||
operationID.value = createUUID()
|
||||
result.value = null
|
||||
current.value = null
|
||||
emit('update:operation', null)
|
||||
}
|
||||
|
||||
function setOpen(value: boolean): void {
|
||||
if (!value && pending.value) return
|
||||
const refreshAfterClose = !value && result.value !== null
|
||||
if (refreshAfterClose) resetOperation()
|
||||
open.value = value
|
||||
if (refreshAfterClose) void refreshQueries()
|
||||
}
|
||||
|
||||
function openDialog(): void {
|
||||
if (props.disabled) return
|
||||
open.value = true
|
||||
}
|
||||
|
||||
async function refreshCurrent(signal: AbortSignal): Promise<AccessKeyDto | null> {
|
||||
try {
|
||||
return (await findAccessKeyForReconciliation(client, props.accessKey.id, signal)) ?? null
|
||||
} catch {
|
||||
refreshFailed.value = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentKey(): Promise<string> {
|
||||
const value = await revealAccessKey(client, props.accessKey.id)
|
||||
return value.key
|
||||
}
|
||||
|
||||
async function refreshQueries(): Promise<void> {
|
||||
try {
|
||||
await applyInvalidationPlan(queryClient, mutationInvalidationPlans.accessKey.rotate)
|
||||
} catch {
|
||||
void queryClient.invalidateQueries({ queryKey: accessKeyResources.collection.queryKey })
|
||||
}
|
||||
}
|
||||
|
||||
async function completeRotation(
|
||||
rotation: AccessKeyRotateResultDto,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
result.value = rotation
|
||||
current.value = rotation
|
||||
if (rotation.replayed) {
|
||||
current.value = (await refreshCurrent(signal)) ?? rotation
|
||||
}
|
||||
emit('rotated', current.value)
|
||||
emit('update:operation', null)
|
||||
operationState.value = 'idle'
|
||||
}
|
||||
|
||||
async function confirmRotate(): Promise<void> {
|
||||
if (pending.value) return
|
||||
if (result.value) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
failed.value = false
|
||||
refreshFailed.value = false
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const activeController = controller
|
||||
try {
|
||||
const rotation = await rotateAccessKey(
|
||||
client,
|
||||
props.accessKey.id,
|
||||
operationID.value,
|
||||
activeController.signal,
|
||||
)
|
||||
if (controller !== activeController || !open.value) return
|
||||
await completeRotation(rotation, activeController.signal)
|
||||
} catch (error: unknown) {
|
||||
if (controller !== activeController || !open.value || error instanceof RequestCancelledError) {
|
||||
return
|
||||
}
|
||||
const outcome = classifyMutationOutcome({ kind: 'error', error, requestSent: true })
|
||||
if (outcome.kind === 'indeterminate' || outcome.kind === 'reconciling') {
|
||||
operationState.value = outcome.kind
|
||||
emit('update:operation', {
|
||||
base: props.accessKey,
|
||||
idempotencyKey: operationID.value,
|
||||
state: outcome.kind,
|
||||
})
|
||||
} else if (outcome.kind === 'failed' && outcome.reason === 'expired-known') {
|
||||
const latest = await refreshCurrent(activeController.signal)
|
||||
if (latest) {
|
||||
current.value = latest
|
||||
result.value = { ...latest, replayed: true }
|
||||
emit('rotated', latest)
|
||||
emit('update:operation', null)
|
||||
operationState.value = 'idle'
|
||||
} else {
|
||||
operationState.value = 'indeterminate'
|
||||
emit('update:operation', {
|
||||
base: props.accessKey,
|
||||
idempotencyKey: operationID.value,
|
||||
state: 'indeterminate',
|
||||
})
|
||||
}
|
||||
} else if (outcome.kind === 'failed' && outcome.reason === 'retryable-precondition') {
|
||||
operationState.value = 'reconciling'
|
||||
emit('update:operation', {
|
||||
base: props.accessKey,
|
||||
idempotencyKey: operationID.value,
|
||||
state: 'reconciling',
|
||||
})
|
||||
} else {
|
||||
failed.value = true
|
||||
if (outcome.kind === 'failed' && outcome.reason === 'rejected') {
|
||||
operationID.value = createUUID()
|
||||
operationState.value = 'idle'
|
||||
emit('update:operation', null)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (controller === activeController) {
|
||||
controller = undefined
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => controller?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppConfirmDialog
|
||||
:open="open"
|
||||
:title="t('accessKeys.rotate.title')"
|
||||
:description="t('accessKeys.rotate.description', { name: accessKey.name })"
|
||||
:close-label="t('accessKeys.rotate.close')"
|
||||
:cancel-label="result ? t('common.close') : t('common.cancel')"
|
||||
:confirm-label="confirmLabel"
|
||||
tone="danger"
|
||||
description-tone="warning"
|
||||
:pending="pending"
|
||||
@update:open="setOpen"
|
||||
@confirm="confirmRotate"
|
||||
>
|
||||
<template #trigger>
|
||||
<AppButton variant="secondary" size="compact" :disabled="disabled" @click="openDialog">
|
||||
<RotateCcw :size="16" aria-hidden="true" />
|
||||
{{ triggerLabel }}
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<div class="access-key-rotate__body">
|
||||
<InlineFeedback tone="warning" appearance="hint">
|
||||
{{ t('accessKeys.rotate.impact') }}
|
||||
</InlineFeedback>
|
||||
<InlineFeedback v-if="feedbackKey" tone="warning">
|
||||
{{ t(feedbackKey) }}
|
||||
</InlineFeedback>
|
||||
<InlineFeedback v-if="failed" tone="danger">{{
|
||||
t('accessKeys.rotate.failed')
|
||||
}}</InlineFeedback>
|
||||
<InlineFeedback v-if="refreshFailed" tone="warning">
|
||||
{{ t('accessKeys.rotate.refreshFailed') }}
|
||||
</InlineFeedback>
|
||||
<div v-if="result" class="access-key-rotate__result" aria-live="polite">
|
||||
<strong>{{
|
||||
t(result.key ? 'accessKeys.rotate.newKey' : 'accessKeys.rotate.currentKey')
|
||||
}}</strong>
|
||||
<CopyChip
|
||||
v-if="open"
|
||||
:key="`${accessKey.id}:${result.updated_at_ms}`"
|
||||
:value="displayKey"
|
||||
:label="t('accessKeys.copy')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
:resolve-value="result.key ? undefined : resolveCurrentKey"
|
||||
/>
|
||||
<small>{{
|
||||
t(result.key ? 'accessKeys.rotate.newKeyHint' : 'accessKeys.rotate.replayedHint')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</AppConfirmDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.access-key-rotate__body,
|
||||
.access-key-rotate__result {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.access-key-rotate__result {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-sunken);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.access-key-rotate__result strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.access-key-rotate__result small {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type AccessKeyDrawerRoute,
|
||||
} from './access-key-collection-route'
|
||||
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
|
||||
import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation'
|
||||
|
||||
const client = useApiClient()
|
||||
const route = useRoute()
|
||||
@@ -67,14 +66,13 @@ const drawerOpen = computed(() => drawerRoute.value !== undefined)
|
||||
const selected = ref<AccessKeyDto | null>(null)
|
||||
const createOperation = ref<PendingAccessKeyCreateOperation | null>(null)
|
||||
const editOperation = ref<PendingAccessKeyEditOperation | null>(null)
|
||||
const rotateOperation = ref<PendingAccessKeyRotateOperation | null>(null)
|
||||
const viewRoot = ref<HTMLElement | null>(null)
|
||||
const collection = ref<InstanceType<typeof AccessKeyCollection> | null>(null)
|
||||
const deletionAnnouncement = ref('')
|
||||
const pendingStatusIDs = ref(new Set<number>())
|
||||
const optimisticEnabled = ref(new Map<number, boolean>())
|
||||
const lockedAccessKeyIDs = computed<ReadonlySet<number>>(() =>
|
||||
rotateOperation.value ? new Set([rotateOperation.value.base.id]) : new Set<number>(),
|
||||
editOperation.value ? new Set([editOperation.value.base.id]) : new Set<number>(),
|
||||
)
|
||||
const statusControllers = new Map<number, AbortController>()
|
||||
const accessKeysQuery = useQuery(accessKeyCollectionQueryOptions(client, filters))
|
||||
@@ -165,11 +163,6 @@ const editOperationNoticeKey = computed(() =>
|
||||
const editOperationName = computed(
|
||||
() => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '',
|
||||
)
|
||||
const rotateOperationNoticeKey = computed(() =>
|
||||
rotateOperation.value?.state === 'reconciling'
|
||||
? 'accessKeys.operation.rotateReconciling'
|
||||
: 'accessKeys.operation.rotateIndeterminate',
|
||||
)
|
||||
let restoreFocus: HTMLElement | null = null
|
||||
const searchDebounce = useDebouncedAction(300)
|
||||
let mounted = true
|
||||
@@ -193,14 +186,8 @@ watch(
|
||||
watch(filters, () => collection.value?.conceal())
|
||||
|
||||
watch(
|
||||
[
|
||||
drawerRoute,
|
||||
data,
|
||||
editOperation,
|
||||
rotateOperation,
|
||||
() => accessKeysQuery.isPlaceholderData.value,
|
||||
],
|
||||
([drawer, pageData, pendingEdit, pendingRotate, placeholder]) => {
|
||||
[drawerRoute, data, editOperation, () => accessKeysQuery.isPlaceholderData.value],
|
||||
([drawer, pageData, pendingEdit, placeholder]) => {
|
||||
if (drawer === undefined || drawer.mode === 'create') {
|
||||
selected.value = null
|
||||
if (drawer?.mode === 'create' && drawer.sourceAccessKeyID !== undefined) {
|
||||
@@ -221,10 +208,6 @@ watch(
|
||||
selected.value = pendingEdit.base
|
||||
return
|
||||
}
|
||||
if (pendingRotate?.base.id === drawer.accessKeyID) {
|
||||
selected.value = pendingRotate.base
|
||||
return
|
||||
}
|
||||
if (pageData && !placeholder) void setDrawerRoute(undefined, true)
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -325,10 +308,6 @@ function openKey(accessKey: AccessKeyDto, trigger: HTMLElement): void {
|
||||
checkEditOperation()
|
||||
return
|
||||
}
|
||||
if (rotateOperation.value && rotateOperation.value.base.id !== accessKey.id) {
|
||||
checkRotateOperation()
|
||||
return
|
||||
}
|
||||
selected.value = accessKey
|
||||
restoreFocus = trigger
|
||||
void setDrawerRoute({ mode: 'edit', accessKeyID: accessKey.id })
|
||||
@@ -342,10 +321,6 @@ function setEditOperation(operation: PendingAccessKeyEditOperation | null): void
|
||||
editOperation.value = operation
|
||||
}
|
||||
|
||||
function setRotateOperation(operation: PendingAccessKeyRotateOperation | null): void {
|
||||
rotateOperation.value = operation
|
||||
}
|
||||
|
||||
function checkCreateOperation(): void {
|
||||
if (!createOperation.value) return
|
||||
selected.value = null
|
||||
@@ -362,15 +337,6 @@ function checkEditOperation(): void {
|
||||
void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id })
|
||||
}
|
||||
|
||||
function checkRotateOperation(): void {
|
||||
const operation = rotateOperation.value
|
||||
if (!operation) return
|
||||
selected.value =
|
||||
data.value?.items.find((accessKey) => accessKey.id === operation.base.id) ?? operation.base
|
||||
restoreFocus = null
|
||||
void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id })
|
||||
}
|
||||
|
||||
async function setDrawerOpen(open: boolean): Promise<void> {
|
||||
if (open) return
|
||||
collection.value?.conceal()
|
||||
@@ -418,10 +384,6 @@ async function handleCostLimitsReset(name: string): Promise<void> {
|
||||
if (mounted) toast.show({ message: t('accessKeys.toast.reset', { name }) })
|
||||
}
|
||||
|
||||
function handleRotated(name: string): void {
|
||||
toast.show({ message: t('accessKeys.toast.rotated', { name }) })
|
||||
}
|
||||
|
||||
function setStatusPending(id: number, pending: boolean): void {
|
||||
const next = new Set(pendingStatusIDs.value)
|
||||
if (pending) next.add(id)
|
||||
@@ -516,13 +478,10 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise<
|
||||
:group-catalog-state="groupCatalogState"
|
||||
:create-operation="createOperation"
|
||||
:edit-operation="selected?.id === editOperation?.base.id ? editOperation : null"
|
||||
:rotate-operation="selected?.id === rotateOperation?.base.id ? rotateOperation : null"
|
||||
@update:create-operation="setCreateOperation"
|
||||
@update:edit-operation="setEditOperation"
|
||||
@update:rotate-operation="setRotateOperation"
|
||||
@update:open="setDrawerOpen"
|
||||
@saved="handleSaved"
|
||||
@rotated="handleRotated"
|
||||
@deleted="handleDeleted"
|
||||
/>
|
||||
|
||||
@@ -542,15 +501,6 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise<
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<section v-if="rotateOperation" class="access-keys__operation" aria-live="polite">
|
||||
<InlineFeedback tone="warning">{{
|
||||
t(rotateOperationNoticeKey, { name: rotateOperation.base.name })
|
||||
}}</InlineFeedback>
|
||||
<AppButton variant="secondary" @click="checkRotateOperation">
|
||||
{{ t('accessKeys.operation.checkResult') }}
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ deletionAnnouncement }}</p>
|
||||
|
||||
<SkeletonSurface
|
||||
|
||||
@@ -10,6 +10,7 @@ export function cloneAccessKeyCreatePayload(
|
||||
payload: CreateAccessKeyRequest,
|
||||
): CreateAccessKeyRequest {
|
||||
return {
|
||||
...(payload.key ? { key: payload.key } : {}),
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
filters: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AccessKeyDto } from '@/api/control/types'
|
||||
import { listAccessKeyCollection, type UpdateAccessKeyRequest } from '@/app/resources/access-keys'
|
||||
|
||||
export interface PendingAccessKeyEditOperation {
|
||||
idempotencyKey?: string
|
||||
base: AccessKeyDto
|
||||
patch: UpdateAccessKeyRequest
|
||||
state: 'indeterminate' | 'reconciling'
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './access-key-scope'
|
||||
|
||||
export interface AccessKeyDraft {
|
||||
key: string
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
filters: AccessKeyFiltersDto
|
||||
@@ -70,6 +71,7 @@ export function createAccessKeyDraft(accessKey?: AccessKeyDto | null): AccessKey
|
||||
)
|
||||
return {
|
||||
name: accessKey?.name ?? '',
|
||||
key: '',
|
||||
status: accessKey?.status ?? 'active',
|
||||
filters,
|
||||
scopeModes: createAccessKeyScopeModes(filters),
|
||||
@@ -86,6 +88,7 @@ export function createAccessKeyDraftFromCreateInput(input: CreateAccessKeyReques
|
||||
const filters = normalizeAccessKeyFilters(input.filters)
|
||||
return {
|
||||
name: input.name,
|
||||
key: input.key ?? '',
|
||||
status: input.status,
|
||||
filters,
|
||||
scopeModes: createAccessKeyScopeModes(filters),
|
||||
@@ -106,6 +109,7 @@ export function createAccessKeyDraftFromUpdate(
|
||||
const expiresAt = patch.expires_at_ms !== undefined ? patch.expires_at_ms : base.expires_at_ms
|
||||
return {
|
||||
name: patch.name ?? base.name,
|
||||
key: patch.key ?? '',
|
||||
status: patch.status ?? base.status,
|
||||
filters,
|
||||
scopeModes: createAccessKeyScopeModes(filters),
|
||||
@@ -154,6 +158,7 @@ export function isAccessKeyDraftValid(
|
||||
|
||||
export function buildCreateAccessKeyInput(draft: AccessKeyDraft): CreateAccessKeyRequest {
|
||||
return {
|
||||
...(draft.key ? { key: draft.key } : {}),
|
||||
name: draft.name.trim(),
|
||||
status: draft.status,
|
||||
filters: materializeDraftFilters(draft),
|
||||
@@ -277,6 +282,7 @@ export function isAccessKeyDraftDirty(draft: AccessKeyDraft, base?: AccessKeyDto
|
||||
if (base) return Object.keys(buildAccessKeyUpdatePatch(base, draft)).length > 0
|
||||
return (
|
||||
draft.name !== initial.name ||
|
||||
draft.key !== initial.key ||
|
||||
draft.status !== initial.status ||
|
||||
draft.expires_at_ms !== initial.expires_at_ms ||
|
||||
draft.rpm_limit !== initial.rpm_limit ||
|
||||
@@ -295,6 +301,7 @@ export function accessKeyMatchesUpdatePatch(
|
||||
base: AccessKeyDto,
|
||||
): boolean {
|
||||
return (
|
||||
!patch.key &&
|
||||
(patch.name === undefined || patch.name === accessKey.name) &&
|
||||
(patch.status === undefined || patch.status === accessKey.status) &&
|
||||
(patch.filters === undefined || equalFilters(patch.filters, accessKey.filters)) &&
|
||||
@@ -368,6 +375,7 @@ export function buildAccessKeyUpdatePatch(
|
||||
draft: AccessKeyDraft,
|
||||
): UpdateAccessKeyRequest {
|
||||
const patch: UpdateAccessKeyRequest = {}
|
||||
if (draft.key !== '') patch.key = draft.key
|
||||
const name = draft.name.trim()
|
||||
const filters = materializeDraftFilters(draft)
|
||||
const expiresAt = expirationValue(draft)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { AccessKeyDto } from '@/api/control/types'
|
||||
|
||||
export interface PendingAccessKeyRotateOperation {
|
||||
base: AccessKeyDto
|
||||
idempotencyKey: string
|
||||
state: 'indeterminate' | 'reconciling'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type AccessKeyStrength = 'weak' | 'fair' | 'strong'
|
||||
|
||||
export function isValidCustomAccessKey(value: string): boolean {
|
||||
return value === '' || /^[\x21-\x7e]{1,256}$/.test(value)
|
||||
}
|
||||
|
||||
// 仅用于前端提示,不作为允许创建密钥的强度门槛。
|
||||
export function estimateAccessKeyStrength(value: string): AccessKeyStrength | null {
|
||||
if (value === '') return null
|
||||
const content = value.startsWith('sk-gl-') ? value.slice(6) : value
|
||||
const lower = content.toLowerCase()
|
||||
const sequential = [
|
||||
'012345678901234567890',
|
||||
'abcdefghijklmnopqrstuvwxyz',
|
||||
'qwertyuiopasdfghjklzxcvbnm',
|
||||
].some((sequence) => sequence.includes(lower))
|
||||
if (
|
||||
content.length < 12 ||
|
||||
new Set(content).size < 4 ||
|
||||
/^(.{1,4})\1+$/.test(content) ||
|
||||
sequential
|
||||
) {
|
||||
return 'weak'
|
||||
}
|
||||
const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z0-9]/].filter((pattern) =>
|
||||
pattern.test(content),
|
||||
).length
|
||||
return content.length >= 20 || (content.length >= 16 && kinds >= 3) ? 'strong' : 'fair'
|
||||
}
|
||||
@@ -1,5 +1,43 @@
|
||||
export default {
|
||||
accessKeys: {
|
||||
customKey: {
|
||||
generate: 'Generate randomly',
|
||||
current: 'Current key:',
|
||||
editPlaceholder: 'Enter a new key, or leave blank to keep it',
|
||||
keepHint: 'Leave blank to keep the current key.',
|
||||
saveHint: 'The new key takes effect only after saving successfully.',
|
||||
generateFailed: 'Unable to generate a secure random key. Retry or enter a key manually.',
|
||||
replaceTitle: 'Replace this access key?',
|
||||
replaceDescription:
|
||||
'After saving successfully, the old key will stop working. Requests already in progress are unaffected. Update every client using this key.',
|
||||
replaceWeakWarning:
|
||||
'The new key is weak and easy to guess, which may allow unauthorized requests and quota consumption.',
|
||||
saveAnyway: 'Save anyway',
|
||||
editKeyIndeterminate:
|
||||
'The key replacement result is unknown. Check the original operation before making another change.',
|
||||
editKeyReconciling:
|
||||
'The key replacement was committed and is still being applied. Continue checking the result.',
|
||||
label: 'Access key',
|
||||
placeholder: 'Leave blank to generate automatically',
|
||||
automaticHint: 'A random key will be generated automatically.',
|
||||
show: 'Show key',
|
||||
hide: 'Hide key',
|
||||
strength: 'Strength: {level}',
|
||||
weak: 'Weak',
|
||||
fair: 'Fair',
|
||||
strong: 'Stronger',
|
||||
estimate: 'Estimate only',
|
||||
weakHint: 'Use a longer key and avoid sequences or repeated characters.',
|
||||
invalid: 'Use up to 256 visible ASCII characters without spaces or control characters.',
|
||||
duplicate: 'This access key already exists. Use a different key.',
|
||||
adminConflict: 'The administrator key cannot be used as an access key.',
|
||||
warningTitle: 'Use a weak key anyway?',
|
||||
warningDescription:
|
||||
'This key is easy to guess and may allow unauthorized requests and quota consumption. Consider changing it or leaving the field blank to generate a random key.',
|
||||
warningClose: 'Close weak key warning',
|
||||
returnToEdit: 'Edit key',
|
||||
createAnyway: 'Create anyway',
|
||||
},
|
||||
distribution: {
|
||||
sort: 'Sort',
|
||||
expiration: 'Expiration',
|
||||
@@ -44,7 +82,6 @@ export default {
|
||||
reset: 'Cost allowances reset for access key “{name}”',
|
||||
enabled: 'Access key “{name}” enabled',
|
||||
disabled: 'Access key “{name}” disabled',
|
||||
rotated: 'Access key “{name}” rotated',
|
||||
},
|
||||
loading: 'Loading access keys…',
|
||||
loadFailed: 'Unable to load access keys.',
|
||||
@@ -113,10 +150,6 @@ export default {
|
||||
'The update to access key “{name}” has an unknown outcome. Check current metadata before editing another access key.',
|
||||
editReconciling:
|
||||
'The update to access key “{name}” was committed and still needs metadata reconciliation.',
|
||||
rotateIndeterminate:
|
||||
'The rotation outcome for access key “{name}” is unknown. Check it using the original operation.',
|
||||
rotateReconciling:
|
||||
'The rotation for access key “{name}” was committed and runtime recovery is still in progress.',
|
||||
checkResult: 'Check result',
|
||||
},
|
||||
status: {
|
||||
@@ -292,28 +325,6 @@ export default {
|
||||
failed: 'Unable to delete the access key.',
|
||||
deletedAnnouncement: 'Deleted access key “{name}”.',
|
||||
},
|
||||
rotate: {
|
||||
open: 'Rotate key',
|
||||
title: 'Rotate this access key now?',
|
||||
description: 'Generate a new access key for “{name}”.',
|
||||
close: 'Close access key rotation confirmation',
|
||||
impact:
|
||||
'The old key becomes invalid as soon as rotation succeeds. Requests already in progress are unaffected.',
|
||||
confirm: 'Rotate now',
|
||||
checkResult: 'Check result',
|
||||
done: 'Done',
|
||||
failed: 'Unable to rotate the access key.',
|
||||
indeterminate: 'The rotation outcome is unknown. Check it using the same operation.',
|
||||
reconciling:
|
||||
'The rotation was committed and runtime recovery is still in progress. Check again.',
|
||||
refreshFailed:
|
||||
'Rotation completed, but current metadata could not be refreshed. Close and refresh the list.',
|
||||
newKey: 'New access key',
|
||||
currentKey: 'Current access key',
|
||||
newKeyHint: 'The new key is active. Update client configuration now.',
|
||||
replayedHint:
|
||||
'This is an idempotent replay. Copy uses the existing Reveal endpoint to read the current key.',
|
||||
},
|
||||
reset: {
|
||||
open: 'Reset allowances',
|
||||
title: 'Reset cost allowances?',
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
export default {
|
||||
accessKeys: {
|
||||
customKey: {
|
||||
generate: 'ランダム生成',
|
||||
current: '現在のキー:',
|
||||
editPlaceholder: '新しいキーを入力。空欄の場合は変更しません',
|
||||
keepHint: '空欄の場合は現在のキーを維持します。',
|
||||
saveHint: '新しいキーは保存成功後に有効になります。',
|
||||
generateFailed: '安全なランダムキーを生成できません。再試行するか手動で入力してください。',
|
||||
replaceTitle: 'アクセスキーを変更しますか?',
|
||||
replaceDescription:
|
||||
'保存に成功すると、古いキーは直ちに無効になります。実行中のリクエストには影響しません。このキーを使用するクライアントの設定を更新してください。',
|
||||
replaceWeakWarning:
|
||||
'新しいキーは推測されやすく、不正なリクエストや利用枠の消費につながる可能性があります。',
|
||||
saveAnyway: '保存を続ける',
|
||||
editKeyIndeterminate:
|
||||
'キー変更の結果が不明です。再度変更する前に、元の操作の結果を確認してください。',
|
||||
editKeyReconciling:
|
||||
'キー変更は確定しましたが、反映処理が続いています。結果の確認を続けてください。',
|
||||
label: 'アクセスキー',
|
||||
placeholder: '空欄の場合は自動生成',
|
||||
automaticHint: 'ランダムなキーを自動生成します。',
|
||||
show: 'キーを表示',
|
||||
hide: 'キーを隠す',
|
||||
strength: '強度:{level}',
|
||||
weak: '弱い',
|
||||
fair: '普通',
|
||||
strong: '比較的強い',
|
||||
estimate: '目安です',
|
||||
weakHint: '長さを増やし、連続または繰り返しの文字を避けてください。',
|
||||
invalid: '空白や制御文字を含まない、256 文字以内の表示可能な ASCII 文字を使用してください。',
|
||||
duplicate: 'このアクセスキーは既に存在します。別のキーを使用してください。',
|
||||
adminConflict: '管理者キーをアクセスキーとして使用することはできません。',
|
||||
warningTitle: '弱いキーを使用しますか?',
|
||||
warningDescription:
|
||||
'このキーは推測されやすく、不正なリクエストや利用枠の消費につながる可能性があります。キーを変更するか、空欄にしてランダムなキーを生成してください。',
|
||||
warningClose: '弱いキーの警告を閉じる',
|
||||
returnToEdit: '修正する',
|
||||
createAnyway: '作成を続ける',
|
||||
},
|
||||
distribution: {
|
||||
sort: '並び順',
|
||||
expiration: '有効期限',
|
||||
@@ -44,7 +82,6 @@ export default {
|
||||
reset: 'アクセスキー「{name}」の費用枠をリセットしました',
|
||||
enabled: 'アクセスキー「{name}」を有効にしました',
|
||||
disabled: 'アクセスキー「{name}」を無効にしました',
|
||||
rotated: 'アクセスキー「{name}」をローテーションしました',
|
||||
},
|
||||
loading: 'アクセスキーを読み込み中…',
|
||||
loadFailed: 'アクセスキーを読み込めません。',
|
||||
@@ -113,10 +150,6 @@ export default {
|
||||
'アクセスキー「{name}」の更新結果は不明です。別のアクセスキーを編集する前に現在のメタデータを確認してください。',
|
||||
editReconciling:
|
||||
'アクセスキー「{name}」の更新は確定しましたが、現在のメタデータを確認する必要があります。',
|
||||
rotateIndeterminate:
|
||||
'アクセスキー「{name}」のローテーション結果は不明です。元の操作で結果を確認してください。',
|
||||
rotateReconciling:
|
||||
'アクセスキー「{name}」のローテーションは確定しましたが、ランタイムの復旧中です。',
|
||||
checkResult: '結果を確認',
|
||||
},
|
||||
status: {
|
||||
@@ -288,27 +321,6 @@ export default {
|
||||
failed: 'アクセスキーを削除できません。',
|
||||
deletedAnnouncement: 'アクセスキー「{name}」を削除しました。',
|
||||
},
|
||||
rotate: {
|
||||
open: 'キーをローテーション',
|
||||
title: 'このアクセスキーを今すぐローテーションしますか?',
|
||||
description: '「{name}」の新しいアクセスキーを生成します。',
|
||||
close: 'アクセスキーのローテーション確認を閉じる',
|
||||
impact:
|
||||
'ローテーション成功後、古いキーは直ちに無効になります。実行中のリクエストには影響しません。',
|
||||
confirm: '今すぐローテーション',
|
||||
checkResult: '結果を確認',
|
||||
done: '完了',
|
||||
failed: 'アクセスキーをローテーションできません。',
|
||||
indeterminate: 'ローテーション結果は不明です。同じ操作で結果を確認してください。',
|
||||
reconciling: 'ローテーションは確定しましたが、ランタイムの復旧中です。再度確認してください。',
|
||||
refreshFailed:
|
||||
'ローテーションは完了しましたが、現在のメタデータを更新できません。閉じて一覧を再読み込みしてください。',
|
||||
newKey: '新しいアクセスキー',
|
||||
currentKey: '現在のアクセスキー',
|
||||
newKeyHint: '新しいキーは有効です。クライアント設定をすぐに更新してください。',
|
||||
replayedHint:
|
||||
'これは冪等リプレイです。コピー時は既存の Reveal API から現在のキーを取得します。',
|
||||
},
|
||||
reset: {
|
||||
open: '費用枠をリセット',
|
||||
title: '費用枠をリセットしますか?',
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
export default {
|
||||
accessKeys: {
|
||||
customKey: {
|
||||
generate: '随机生成',
|
||||
current: '当前密钥:',
|
||||
editPlaceholder: '输入新密钥,留空保持不变',
|
||||
keepHint: '留空将保持当前密钥。',
|
||||
saveHint: '新密钥仅在保存成功后生效。',
|
||||
generateFailed: '无法生成安全的随机密钥,请重试或手动输入。',
|
||||
replaceTitle: '确认更换访问密钥?',
|
||||
replaceDescription:
|
||||
'保存成功后,旧密钥将立即失效;已经开始的请求不受影响。请同步更新使用此密钥的客户端。',
|
||||
replaceWeakWarning: '新密钥强度较弱,容易被猜测,可能导致未经授权的调用和额度消耗。',
|
||||
saveAnyway: '仍然保存',
|
||||
editKeyIndeterminate: '密钥更换结果未知;请核对原操作,避免重复更换。',
|
||||
editKeyReconciling: '密钥更换已提交,正在恢复生效状态;请继续核对结果。',
|
||||
label: '访问密钥',
|
||||
placeholder: '留空由系统自动生成',
|
||||
automaticHint: '将自动生成随机密钥。',
|
||||
show: '显示密钥',
|
||||
hide: '隐藏密钥',
|
||||
strength: '强度:{level}',
|
||||
weak: '弱',
|
||||
fair: '一般',
|
||||
strong: '较强',
|
||||
estimate: '仅供参考',
|
||||
weakHint: '建议增加长度,避免连续或重复字符。',
|
||||
invalid: '密钥最多 256 个字符,仅支持英文字母、数字和英文符号,不允许空白。',
|
||||
duplicate: '此访问密钥已存在,请使用其他密钥。',
|
||||
adminConflict: '不能使用管理员密钥作为访问密钥。',
|
||||
warningTitle: '仍要使用弱密钥?',
|
||||
warningDescription:
|
||||
'该密钥容易被猜测,可能导致未经授权的调用和额度消耗。建议修改密钥,或清空输入以自动生成。',
|
||||
warningClose: '关闭弱密钥提示',
|
||||
returnToEdit: '返回修改',
|
||||
createAnyway: '仍然创建',
|
||||
},
|
||||
distribution: {
|
||||
sort: '排序',
|
||||
expiration: '有效期',
|
||||
@@ -44,7 +79,6 @@ export default {
|
||||
reset: '访问密钥“{name}”的费用额度已重置',
|
||||
enabled: '访问密钥“{name}”已启用',
|
||||
disabled: '访问密钥“{name}”已停用',
|
||||
rotated: '访问密钥“{name}”已轮换',
|
||||
},
|
||||
loading: '正在加载访问密钥…',
|
||||
loadFailed: '无法加载访问密钥。',
|
||||
@@ -109,8 +143,6 @@ export default {
|
||||
reconciling: '访问密钥已提交,运行时仍在恢复。',
|
||||
editIndeterminate: '访问密钥“{name}”的更新结果未知;编辑其他访问密钥前请先核对当前元数据。',
|
||||
editReconciling: '访问密钥“{name}”的更新已提交,仍需核对当前元数据。',
|
||||
rotateIndeterminate: '访问密钥“{name}”的轮换结果未知;请使用原操作核对结果。',
|
||||
rotateReconciling: '访问密钥“{name}”的轮换已提交,运行时仍在恢复。',
|
||||
checkResult: '核对结果',
|
||||
},
|
||||
status: {
|
||||
@@ -271,24 +303,6 @@ export default {
|
||||
failed: '无法删除访问密钥。',
|
||||
deletedAnnouncement: '已删除访问密钥“{name}”。',
|
||||
},
|
||||
rotate: {
|
||||
open: '轮换密钥',
|
||||
title: '立即轮换此访问密钥?',
|
||||
description: '为“{name}”生成新的访问密钥。',
|
||||
close: '关闭访问密钥轮换确认',
|
||||
impact: '轮换成功后,旧密钥立即失效;已经开始的请求不受影响。',
|
||||
confirm: '立即轮换',
|
||||
checkResult: '核对结果',
|
||||
done: '完成',
|
||||
failed: '无法轮换访问密钥。',
|
||||
indeterminate: '轮换结果未知;请使用同一操作核对结果。',
|
||||
reconciling: '轮换已提交,运行时仍在恢复;请继续核对结果。',
|
||||
refreshFailed: '轮换已完成,但当前元数据刷新失败;请关闭后刷新列表。',
|
||||
newKey: '新访问密钥',
|
||||
currentKey: '当前访问密钥',
|
||||
newKeyHint: '新密钥已生效,请立即更新客户端配置。',
|
||||
replayedHint: '这是幂等重放结果;复制时会通过现有 Reveal 接口读取当前密钥。',
|
||||
},
|
||||
reset: {
|
||||
open: '重置额度',
|
||||
title: '重置费用额度?',
|
||||
|
||||
Reference in new issue
Block a user