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.
This commit is contained in:
@@ -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),
|
||||
|
||||
192
internal/app/key_self_service.go
Normal file
192
internal/app/key_self_service.go
Normal 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"})
|
||||
}
|
||||
162
internal/app/key_self_service_svc.go
Normal file
162
internal/app/key_self_service_svc.go
Normal 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")
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user