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

@@ -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))
}
}