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

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