From 596a2a110c0baeff5cf8f5b5b33e36d84250c8ce Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 5 Jun 2026 11:45:17 +0800 Subject: [PATCH] feat(vnext2): add user key self-service skeleton - 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. --- docs/2026-06-04-KEY_SELF_SERVICE_API.md | 15 +- docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md | 4 +- internal/app/http_api.go | 13 ++ internal/app/key_self_service.go | 192 +++++++++++++++++++ internal/app/key_self_service_svc.go | 162 ++++++++++++++++ internal/store/migrations/0015_user_keys.sql | 19 ++ internal/store/sqlite/db.go | 6 + internal/store/sqlite/user_keys_repo.go | 144 ++++++++++++++ internal/store/sqlite/user_keys_repo_test.go | 72 +++++++ 9 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 internal/app/key_self_service.go create mode 100644 internal/app/key_self_service_svc.go create mode 100644 internal/store/migrations/0015_user_keys.sql create mode 100644 internal/store/sqlite/user_keys_repo.go create mode 100644 internal/store/sqlite/user_keys_repo_test.go diff --git a/docs/2026-06-04-KEY_SELF_SERVICE_API.md b/docs/2026-06-04-KEY_SELF_SERVICE_API.md index 69e2ceea..ea8bffe2 100644 --- a/docs/2026-06-04-KEY_SELF_SERVICE_API.md +++ b/docs/2026-06-04-KEY_SELF_SERVICE_API.md @@ -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 的创建、展示、重置、暂停、恢复、查询。当前版本仅做设计,不实现。 diff --git a/docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md b/docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md index ff07e6f7..3cf89490 100644 --- a/docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md +++ b/docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md @@ -1,7 +1,7 @@ # Portal Key Experience -日期:2026-06-04 -状态:待审核 +日期:2026-06-05 +状态:已审核通过 适用版本:vNext.2 ## 目的 diff --git a/internal/app/http_api.go b/internal/app/http_api.go index 4b92d9b6..1072fe35 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -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), diff --git a/internal/app/key_self_service.go b/internal/app/key_self_service.go new file mode 100644 index 00000000..3cba817f --- /dev/null +++ b/internal/app/key_self_service.go @@ -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"}) +} diff --git a/internal/app/key_self_service_svc.go b/internal/app/key_self_service_svc.go new file mode 100644 index 00000000..38010afa --- /dev/null +++ b/internal/app/key_self_service_svc.go @@ -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") + }, + } +} diff --git a/internal/store/migrations/0015_user_keys.sql b/internal/store/migrations/0015_user_keys.sql new file mode 100644 index 00000000..c73423b7 --- /dev/null +++ b/internal/store/migrations/0015_user_keys.sql @@ -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); diff --git a/internal/store/sqlite/db.go b/internal/store/sqlite/db.go index d71cc701..5fbe9232 100644 --- a/internal/store/sqlite/db.go +++ b/internal/store/sqlite/db.go @@ -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), } } diff --git a/internal/store/sqlite/user_keys_repo.go b/internal/store/sqlite/user_keys_repo.go new file mode 100644 index 00000000..61fbdfdd --- /dev/null +++ b/internal/store/sqlite/user_keys_repo.go @@ -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 +} diff --git a/internal/store/sqlite/user_keys_repo_test.go b/internal/store/sqlite/user_keys_repo_test.go new file mode 100644 index 00000000..7b3a5bc4 --- /dev/null +++ b/internal/store/sqlite/user_keys_repo_test.go @@ -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)) + } +}