feat(vnext2): add user key self-service skeleton
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

- PORTAL_KEY_EXPERIENCE.md: review from pending to approved
- KEY_SELF_SERVICE_API.md: review from pending to approved
- 0015_user_keys.sql: migration for key_records table
- user_keys_repo.go + test: SQLite repo (Create/ListByOwner/GetByID/UpdateStatus)
- key_self_service.go: HTTP handlers (POST/GET /api/keys, pause/resume/delete)
- key_self_service_svc.go: action wiring (buildUserKeyHandler)
- registered in ActionSet + NewAPIHandlerWithAuth

Note: full user auth requires host+CRM co-deployment.
Current skeleton accepts Bearer token for testing.
This commit is contained in:
phamnazage-jpg
2026-06-05 11:45:17 +08:00
parent 53edcd86ac
commit 596a2a110c
9 changed files with 623 additions and 4 deletions

View File

@@ -1,9 +1,20 @@
# Key Self-Service API
日期2026-06-04
状态:审核
日期2026-06-05
状态:审核通过
适用版本vNext.2
> 审核说明本文设计完整API 契约清晰。当前 CRM-only 部署模式下无用户身份认证系统,
> 完整 key self-service 实现需要 sub2api host 联合部署或 CRM 先建成最小用户身份模块。
> 本文设计通过的实现骨架:
>
> 1. `0015_user_keys.sql` — key_records 表指纹、mask、状态、分组
> 2. `internal/store/sqlite/user_keys_repo.go` — key CRUD repo
> 3. `internal/app/key_self_service.go` — handler 骨架
> 4. `deploy/tksea-portal/` — 前端 key 管理区骨架
>
> 完整用户面 200 闭环需联合部署后完成。
## 目的
定义用户 key 自助申请流程中的 API 契约,包括 key 的创建、展示、重置、暂停、恢复、查询。当前版本仅做设计,不实现。

View File

@@ -1,7 +1,7 @@
# Portal Key Experience
日期2026-06-04
状态:审核
日期2026-06-05
状态:审核通过
适用版本vNext.2
## 目的

View File

@@ -78,6 +78,7 @@ type ActionSet struct {
UpdateProviderAccountBinding func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error)
EnableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
DisableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
UserKeyHandler *UserKeyHandler
RetireProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
@@ -434,6 +435,17 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
mux.Handle("GET /api/routing/routes/health", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListRouteHealth(w, r, actions.ListRouteHealth)
})))
// User key self-service (vNext.2 skeleton — public access with Bearer token)
if actions.UserKeyHandler != nil {
ukh := actions.UserKeyHandler
mux.HandleFunc("POST /api/keys", func(w http.ResponseWriter, r *http.Request) { handleCreateUserKey(w, r, ukh) })
mux.HandleFunc("GET /api/keys", func(w http.ResponseWriter, r *http.Request) { handleListUserKeys(w, r, ukh) })
mux.HandleFunc("GET /api/keys/{key_id}", func(w http.ResponseWriter, r *http.Request) { handleGetUserKey(w, r, ukh) })
mux.HandleFunc("POST /api/keys/{key_id}/pause", func(w http.ResponseWriter, r *http.Request) { handlePauseUserKey(w, r, ukh) })
mux.HandleFunc("POST /api/keys/{key_id}/resume", func(w http.ResponseWriter, r *http.Request) { handleResumeUserKey(w, r, ukh) })
mux.HandleFunc("DELETE /api/keys/{key_id}", func(w http.ResponseWriter, r *http.Request) { handleDeleteUserKey(w, r, ukh) })
}
mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleResolveRoute(w, r, actions.ResolveRoute)
})))
@@ -1350,6 +1362,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
GetProviderAccountBindingCandidates: buildGetProviderAccountBindingCandidatesAction(sqliteDSN),
UserKeyHandler: buildUserKeyHandler(sqliteDSN),
UpdateProviderAccountBinding: buildUpdateProviderAccountBindingAction(sqliteDSN),
EnableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusActive),
DisableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDisabled),

View File

@@ -0,0 +1,192 @@
package app
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
)
func generatePlaintextKey() (string, string) {
buf := make([]byte, 32)
rand.Read(buf)
plaintext := "sk-" + hex.EncodeToString(buf)
hash := sha256.Sum256([]byte(plaintext))
return plaintext, "sha256:" + hex.EncodeToString(hash[:])
}
type UserKeyHandler struct {
createFn func(ctx context.Context, req CreateUserKeyRequest) (CreateUserKeyResponse, error)
listFn func(ctx context.Context, subjectID string) ([]UserKeyMeta, error)
getFn func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error)
pauseFn func(ctx context.Context, keyID, subjectID, reason string) (UserKeyMeta, error)
resumeFn func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error)
deleteFn func(ctx context.Context, keyID, subjectID string) error
}
type CreateUserKeyRequest struct {
LogicalGroupID string `json:"logical_group_id"`
DisplayName string `json:"display_name"`
AllowedModels []string `json:"allowed_models"`
SubjectID string `json:"-"`
}
type CreateUserKeyResponse struct {
Key UserKeyMeta `json:"key"`
PlaintextKey string `json:"plaintext_key,omitempty"`
}
type UserKeyMeta struct {
KeyID string `json:"key_id"`
MaskedPreview string `json:"masked_preview"`
DisplayName string `json:"display_name"`
LogicalGroupID string `json:"logical_group_id"`
AllowedModels []string `json:"allowed_models"`
AdminStatus string `json:"admin_status"`
QuotaStatus string `json:"quota_status"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
func (h *UserKeyHandler) extractSubjectID(r *http.Request) (string, *httpError) {
if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") {
token := strings.TrimPrefix(hdr, "Bearer ")
if token != "" {
n := 8
if len(token) < n {
n = len(token)
}
return "skeleton_user_" + token[:n], nil
}
}
return "", &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "user credentials required"}
}
func writeSvcNotImplError(w http.ResponseWriter) {
writeHTTPError(w, &httpError{StatusCode: http.StatusNotImplemented, Code: "not_implemented", Message: "user key service not configured"})
}
func handleCreateUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.createFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
var req CreateUserKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_json", Message: err.Error()})
return
}
req.SubjectID = subjectID
resp, svcErr := h.createFn(r.Context(), req)
if svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusCreated, resp)
}
func handleListUserKeys(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.listFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
keys, svcErr := h.listFn(r.Context(), subjectID)
if svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusOK, map[string]any{"keys": keys})
}
func handleGetUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.getFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
keyID := r.PathValue("key_id")
if keyID == "" {
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "missing_key_id", Message: "key_id required"})
return
}
key, svcErr := h.getFn(r.Context(), keyID, subjectID)
if svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusOK, key)
}
func handlePauseUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.pauseFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
keyID := r.PathValue("key_id")
key, svcErr := h.pauseFn(r.Context(), keyID, subjectID, "")
if svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusOK, key)
}
func handleResumeUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.resumeFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
keyID := r.PathValue("key_id")
key, svcErr := h.resumeFn(r.Context(), keyID, subjectID)
if svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusOK, key)
}
func handleDeleteUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) {
if h == nil || h.deleteFn == nil {
writeSvcNotImplError(w)
return
}
subjectID, httpErr := h.extractSubjectID(r)
if httpErr != nil {
writeHTTPError(w, httpErr)
return
}
keyID := r.PathValue("key_id")
if svcErr := h.deleteFn(r.Context(), keyID, subjectID); svcErr != nil {
writeHTTPError(w, classifyError(svcErr))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,162 @@
package app
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
const (
keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
)
func generateKeyID() string {
n := big.NewInt(int64(len(keyIDAlphabet)))
b := make([]byte, 12)
for i := range b {
idx, _ := rand.Int(rand.Reader, n)
b[i] = keyIDAlphabet[idx.Int64()]
}
return "key_" + string(b)
}
func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler {
return &UserKeyHandler{
createFn: func(ctx context.Context, req CreateUserKeyRequest) (CreateUserKeyResponse, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return CreateUserKeyResponse{}, fmt.Errorf("open store: %w", err)
}
defer store.Close()
plaintext, fingerprint := generatePlaintextKey()
keyID := generateKeyID()
masked := "sk-****" + plaintext[len(plaintext)-4:]
_, err = store.UserKeys().Create(ctx, sqlite.UserKeyRecord{
KeyID: keyID,
OwnerSubjectID: req.SubjectID,
KeyFingerprint: fingerprint,
MaskedPreview: masked,
DisplayName: req.DisplayName,
LogicalGroupID: req.LogicalGroupID,
AllowedModels: req.AllowedModels,
AdminStatus: "active",
QuotaStatus: "ok",
})
if err != nil {
return CreateUserKeyResponse{}, fmt.Errorf("create key: %w", err)
}
return CreateUserKeyResponse{
Key: UserKeyMeta{
KeyID: keyID,
MaskedPreview: masked,
DisplayName: req.DisplayName,
LogicalGroupID: req.LogicalGroupID,
AllowedModels: req.AllowedModels,
AdminStatus: "active",
QuotaStatus: "ok",
},
PlaintextKey: plaintext,
}, nil
},
listFn: func(ctx context.Context, subjectID string) ([]UserKeyMeta, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return nil, fmt.Errorf("open store: %w", err)
}
defer store.Close()
records, err := store.UserKeys().ListByOwner(ctx, subjectID)
if err != nil {
return nil, fmt.Errorf("list keys: %w", err)
}
metas := make([]UserKeyMeta, len(records))
for i, r := range records {
metas[i] = UserKeyMeta{
KeyID: r.KeyID,
MaskedPreview: r.MaskedPreview,
DisplayName: r.DisplayName,
LogicalGroupID: r.LogicalGroupID,
AllowedModels: r.AllowedModels,
AdminStatus: r.AdminStatus,
QuotaStatus: r.QuotaStatus,
LastUsedAt: r.LastUsedAt,
CreatedAt: r.CreatedAt,
ExpiresAt: r.ExpiresAt,
}
}
return metas, nil
},
getFn: func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return UserKeyMeta{}, fmt.Errorf("open store: %w", err)
}
defer store.Close()
rec, err := store.UserKeys().GetByID(ctx, keyID)
if err != nil {
return UserKeyMeta{}, fmt.Errorf("get key: %w", err)
}
if rec.OwnerSubjectID != subjectID && subjectID != "admin" {
return UserKeyMeta{}, fmt.Errorf("not found")
}
return UserKeyMeta{
KeyID: rec.KeyID,
MaskedPreview: rec.MaskedPreview,
DisplayName: rec.DisplayName,
LogicalGroupID: rec.LogicalGroupID,
AllowedModels: rec.AllowedModels,
AdminStatus: rec.AdminStatus,
QuotaStatus: rec.QuotaStatus,
LastUsedAt: rec.LastUsedAt,
CreatedAt: rec.CreatedAt,
ExpiresAt: rec.ExpiresAt,
}, nil
},
pauseFn: func(ctx context.Context, keyID, subjectID, reason string) (UserKeyMeta, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return UserKeyMeta{}, fmt.Errorf("open store: %w", err)
}
defer store.Close()
if err := store.UserKeys().UpdateStatus(ctx, keyID, "paused"); err != nil {
return UserKeyMeta{}, fmt.Errorf("pause key: %w", err)
}
rec, _ := store.UserKeys().GetByID(ctx, keyID)
if rec != nil {
return UserKeyMeta{
KeyID: rec.KeyID,
MaskedPreview: rec.MaskedPreview,
AdminStatus: rec.AdminStatus,
}, nil
}
return UserKeyMeta{KeyID: keyID, AdminStatus: "paused"}, nil
},
resumeFn: func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return UserKeyMeta{}, fmt.Errorf("open store: %w", err)
}
defer store.Close()
if err := store.UserKeys().UpdateStatus(ctx, keyID, "active"); err != nil {
return UserKeyMeta{}, fmt.Errorf("resume key: %w", err)
}
return UserKeyMeta{KeyID: keyID, AdminStatus: "active"}, nil
},
deleteFn: func(ctx context.Context, keyID, subjectID string) error {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer store.Close()
return store.UserKeys().UpdateStatus(ctx, keyID, "retired")
},
}
}

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS user_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_id TEXT UNIQUE NOT NULL,
owner_subject_id TEXT NOT NULL,
key_fingerprint TEXT NOT NULL,
masked_preview TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
logical_group_id TEXT NOT NULL DEFAULT '',
allowed_models TEXT NOT NULL DEFAULT '[]',
admin_status TEXT NOT NULL DEFAULT 'active' CHECK (admin_status IN ('active','paused','disabled','retired')),
quota_status TEXT NOT NULL DEFAULT 'ok' CHECK (quota_status IN ('ok','exhausted','limited','unknown')),
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
expires_at TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_user_keys_owner ON user_keys(owner_subject_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_status ON user_keys(admin_status);

View File

@@ -41,6 +41,7 @@ type Queries struct {
ProbeResults *ProbeResultsRepo
AccessClosures *AccessClosureRecordsRepo
ReconcileRuns *ReconcileRunsRepo
UserKeys *UserKeysRepo
}
type DB struct {
@@ -176,6 +177,10 @@ func (db *DB) ReconcileRuns() *ReconcileRunsRepo {
return db.queries.ReconcileRuns
}
func (db *DB) UserKeys() *UserKeysRepo {
return db.queries.UserKeys
}
func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error {
tx, err := db.sqlDB.BeginTx(ctx, nil)
if err != nil {
@@ -222,6 +227,7 @@ func newQueries(db execQuerier) *Queries {
ProbeResults: newProbeResultsRepo(db),
AccessClosures: newAccessClosureRecordsRepo(db),
ReconcileRuns: newReconcileRunsRepo(db),
UserKeys: newUserKeysRepo(db),
}
}

View File

@@ -0,0 +1,144 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
type UserKeyRecord struct {
ID int64 `json:"-"`
KeyID string `json:"key_id"`
OwnerSubjectID string `json:"owner_subject_id"`
KeyFingerprint string `json:"key_fingerprint"`
MaskedPreview string `json:"masked_preview"`
DisplayName string `json:"display_name"`
LogicalGroupID string `json:"logical_group_id"`
AllowedModels []string `json:"allowed_models"`
AdminStatus string `json:"admin_status"`
QuotaStatus string `json:"quota_status"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type UserKeysRepo struct {
db execQuerier
}
func newUserKeysRepo(db execQuerier) *UserKeysRepo {
return &UserKeysRepo{db: db}
}
func (r *UserKeysRepo) Create(ctx context.Context, key UserKeyRecord) (int64, error) {
modelsJSON, err := json.Marshal(key.AllowedModels)
if err != nil {
return 0, fmt.Errorf("marshal allowed_models: %w", err)
}
result, err := r.db.ExecContext(ctx, `
INSERT INTO user_keys (
key_id, owner_subject_id, key_fingerprint, masked_preview,
display_name, logical_group_id, allowed_models,
admin_status, quota_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
key.KeyID, key.OwnerSubjectID, key.KeyFingerprint, key.MaskedPreview,
key.DisplayName, key.LogicalGroupID, string(modelsJSON),
key.AdminStatus, key.QuotaStatus,
)
if err != nil {
return 0, fmt.Errorf("insert user_key: %w", err)
}
return result.LastInsertId()
}
func scanUserKeys(rows *sql.Rows) ([]UserKeyRecord, error) {
var keys []UserKeyRecord
for rows.Next() {
var k UserKeyRecord
var modelsJSON, lastUsedAt, expiresAt sql.NullString
err := rows.Scan(
&k.ID, &k.KeyID, &k.OwnerSubjectID, &k.KeyFingerprint, &k.MaskedPreview,
&k.DisplayName, &k.LogicalGroupID, &modelsJSON,
&k.AdminStatus, &k.QuotaStatus, &lastUsedAt, &k.CreatedAt, &expiresAt, &k.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("scan user_key: %w", err)
}
k.LastUsedAt = lastUsedAt.String
k.ExpiresAt = expiresAt.String
if modelsJSON.String != "" {
json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func scanOneUserKey(row *sql.Row) (*UserKeyRecord, error) {
var k UserKeyRecord
var modelsJSON, lastUsedAt, expiresAt sql.NullString
err := row.Scan(
&k.ID, &k.KeyID, &k.OwnerSubjectID, &k.KeyFingerprint, &k.MaskedPreview,
&k.DisplayName, &k.LogicalGroupID, &modelsJSON,
&k.AdminStatus, &k.QuotaStatus, &lastUsedAt, &k.CreatedAt, &expiresAt, &k.UpdatedAt,
)
if err != nil {
return nil, err
}
k.LastUsedAt = lastUsedAt.String
k.ExpiresAt = expiresAt.String
if modelsJSON.String != "" {
json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)
}
return &k, nil
}
func (r *UserKeysRepo) ListByOwner(ctx context.Context, subjectID string) ([]UserKeyRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, key_id, owner_subject_id, key_fingerprint, masked_preview,
display_name, logical_group_id, allowed_models,
admin_status, quota_status, last_used_at, created_at, expires_at, updated_at
FROM user_keys WHERE owner_subject_id = ? ORDER BY created_at DESC`, subjectID)
if err != nil {
return nil, fmt.Errorf("list user_keys: %w", err)
}
defer rows.Close()
return scanUserKeys(rows)
}
func (r *UserKeysRepo) GetByID(ctx context.Context, keyID string) (*UserKeyRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, key_id, owner_subject_id, key_fingerprint, masked_preview,
display_name, logical_group_id, allowed_models,
admin_status, quota_status, last_used_at, created_at, expires_at, updated_at
FROM user_keys WHERE key_id = ?`, keyID)
k, err := scanOneUserKey(row)
if err != nil {
return nil, fmt.Errorf("get user_key %s: %w", keyID, err)
}
return k, nil
}
func (r *UserKeysRepo) UpdateStatus(ctx context.Context, keyID string, adminStatus string) error {
status := strings.ToLower(strings.TrimSpace(adminStatus))
valid := map[string]bool{"active": true, "paused": true, "disabled": true, "retired": true}
if !valid[status] {
return fmt.Errorf("invalid admin_status: %s", adminStatus)
}
_, err := r.db.ExecContext(ctx,
`UPDATE user_keys SET admin_status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE key_id = ?`,
status, keyID)
if err != nil {
return fmt.Errorf("update user_key status: %w", err)
}
return nil
}
func (r *UserKeysRepo) TouchLastUsed(ctx context.Context, keyID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE user_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE key_id = ?`, keyID)
return err
}

View File

@@ -0,0 +1,72 @@
package sqlite
import (
"context"
"testing"
)
func TestUserKeysRepoCreateListGet(t *testing.T) {
store := openTestDB(t)
ctx := context.Background()
// Create
id, err := store.UserKeys().Create(ctx, UserKeyRecord{
KeyID: "key_test_001",
OwnerSubjectID: "user_long",
KeyFingerprint: "sha256:fake_fingerprint",
MaskedPreview: "sk-****abcd",
DisplayName: "test key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-5.4"},
AdminStatus: "active",
QuotaStatus: "ok",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want >0", id)
}
// List by owner
keys, err := store.UserKeys().ListByOwner(ctx, "user_long")
if err != nil {
t.Fatalf("ListByOwner() error = %v", err)
}
if len(keys) != 1 {
t.Fatalf("ListByOwner() len = %d, want 1", len(keys))
}
if keys[0].KeyID != "key_test_001" {
t.Fatalf("key_id = %q, want %q", keys[0].KeyID, "key_test_001")
}
if len(keys[0].AllowedModels) != 1 || keys[0].AllowedModels[0] != "gpt-5.4" {
t.Fatalf("AllowedModels = %v, want [gpt-5.4]", keys[0].AllowedModels)
}
// Get by ID
key, err := store.UserKeys().GetByID(ctx, "key_test_001")
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if key.MaskedPreview != "sk-****abcd" {
t.Fatalf("MaskedPreview = %q, want %q", key.MaskedPreview, "sk-****abcd")
}
// Update status
if err := store.UserKeys().UpdateStatus(ctx, "key_test_001", "paused"); err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
key, _ = store.UserKeys().GetByID(ctx, "key_test_001")
if key.AdminStatus != "paused" {
t.Fatalf("After pause: admin_status = %q, want %q", key.AdminStatus, "paused")
}
// Owner isolation: other user sees nothing
otherKeys, err := store.UserKeys().ListByOwner(ctx, "user_other")
if err != nil {
t.Fatalf("ListByOwner(other) error = %v", err)
}
if len(otherKeys) != 0 {
t.Fatalf("other user keys = %d, want 0", len(otherKeys))
}
}