* ensureSubjectHasAccess now uses real SubjectID, not fixed 'portal-user' * CreateUserKey/ResetUserKey metadata (masked_preview, key_fingerprint) based on actual returned key * PauseManagedSubscriptionAccess/ResumeManagedSubscriptionAccess update host user allowed_groups * Remote43 hot-updated with singleton CRM (secondary instance killed to avoid SQLITE_BUSY) * Fresh JWT issued for remote43 host adapter * Real E2E: create=201, chat-before=200, pause=200, resume=200, chat-resumed=200 * Known gap: paused chat still 200 (host auth cache delay, not CRM code)
432 lines
16 KiB
Go
432 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"sub2api-cn-relay-manager/internal/host/sub2api"
|
|
"sub2api-cn-relay-manager/internal/store/sqlite"
|
|
)
|
|
|
|
const (
|
|
keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
defaultKeyRateLimitPerHour = 5
|
|
defaultKeyResetPerDay = 2
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
// resolveLogicalGroupHost resolves a logical_group_id to host + shadow group host resource ID.
|
|
func resolveLogicalGroupHost(ctx context.Context, store *sqlite.DB, logicalGroupID string) (sqlite.LogicalGroup, sqlite.LogicalGroupRoute, sqlite.Host, *sub2api.Client, error) {
|
|
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, logicalGroupID)
|
|
if err != nil {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("logical group %q: %w", logicalGroupID, err)
|
|
}
|
|
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, logicalGroupID)
|
|
if err != nil {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("list routes for %q: %w", logicalGroupID, err)
|
|
}
|
|
if len(routes) == 0 {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("no active route for logical group %q", logicalGroupID)
|
|
}
|
|
// pick first active route by priority
|
|
var firstRoute *sqlite.LogicalGroupRoute
|
|
for i, r := range routes {
|
|
if isActiveStatus(r.Status) {
|
|
firstRoute = &routes[i]
|
|
break
|
|
}
|
|
}
|
|
if firstRoute == nil {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("no active route for logical group %q", logicalGroupID)
|
|
}
|
|
hostRow, err := store.Hosts().GetByHostID(ctx, firstRoute.ShadowHostID)
|
|
if err != nil {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("host %q: %w", firstRoute.ShadowHostID, err)
|
|
}
|
|
client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow))
|
|
if err != nil {
|
|
return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("host client %q: %w", hostRow.HostID, err)
|
|
}
|
|
return group, *firstRoute, hostRow, client, nil
|
|
}
|
|
|
|
// resolveShadowHostGroupID resolves a shadow_group_id from a route to a host-resolved group ID.
|
|
func resolveShadowHostGroupID(ctx context.Context, client *sub2api.Client, route sqlite.LogicalGroupRoute) (string, error) {
|
|
sgID := strings.TrimSpace(route.ShadowGroupID)
|
|
// If already a numeric ID, use as-is
|
|
if _, err := strconv.ParseInt(sgID, 10, 64); err == nil {
|
|
return sgID, nil
|
|
}
|
|
// Otherwise look up via managed resources
|
|
result, err := client.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{GroupName: sgID})
|
|
if err != nil {
|
|
return "", fmt.Errorf("list managed groups for %q: %w", sgID, err)
|
|
}
|
|
if len(result.Groups) == 1 {
|
|
return result.Groups[0].ID, nil
|
|
}
|
|
if len(result.Groups) > 1 {
|
|
return "", fmt.Errorf("multiple host groups matched shadow_group_id %q", sgID)
|
|
}
|
|
return "", fmt.Errorf("shadow group %q not found on host", sgID)
|
|
}
|
|
|
|
func ensureSubjectHasAccess(ctx context.Context, client *sub2api.Client, subjectSelector, hostGroupID string) (apiKey string, err error) {
|
|
accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
|
UserSelector: strings.TrimSpace(subjectSelector),
|
|
GroupID: hostGroupID,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("ensure subscription access: %w", err)
|
|
}
|
|
apiKey = strings.TrimSpace(accessRef.APIKey)
|
|
if apiKey == "" {
|
|
return "", fmt.Errorf("managed subscription access returned empty api key")
|
|
}
|
|
return apiKey, nil
|
|
}
|
|
|
|
func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler {
|
|
return &UserKeyHandler{
|
|
createFn: func(ctx context.Context, req CreateUserKeyRequest) (CreateUserKeyResponse, error) {
|
|
if strings.TrimSpace(req.SubjectID) == "" {
|
|
return CreateUserKeyResponse{}, &httpError{StatusCode: 401, Code: "unauthorized", Message: "user credentials required"}
|
|
}
|
|
if strings.TrimSpace(req.LogicalGroupID) == "" {
|
|
return CreateUserKeyResponse{}, &httpError{StatusCode: 400, Code: "bad_request", Message: "logical_group_id is required"}
|
|
}
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, fmt.Errorf("open store: %w", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
windowStart := time.Now().UTC().Format("2006-01-02T15:00:00Z")
|
|
count, err := store.SubjectRateLimits().IncrementWindow(ctx, req.SubjectID, "create", windowStart)
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, fmt.Errorf("increment create rate limit: %w", err)
|
|
}
|
|
if count > defaultKeyRateLimitPerHour {
|
|
return CreateUserKeyResponse{}, &httpError{StatusCode: 429, Code: "rate_limited", Message: "create key rate limit exceeded"}
|
|
}
|
|
|
|
// Resolve logical group → host → group ID → ensure subscription access
|
|
_, route, hostRow, client, err := resolveLogicalGroupHost(ctx, store, req.LogicalGroupID)
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, fmt.Errorf("resolve host for %q: %w", req.LogicalGroupID, err)
|
|
}
|
|
hostGroupID, err := resolveShadowHostGroupID(ctx, client, route)
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err)
|
|
}
|
|
apiKey, err := ensureSubjectHasAccess(ctx, client, req.SubjectID, hostGroupID)
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, fmt.Errorf("ensure access for %q: %w", req.LogicalGroupID, err)
|
|
}
|
|
|
|
fingerprint := "sha256:" + sha256Hex(apiKey)
|
|
keyID := generateKeyID()
|
|
masked := "sk-****" + apiKey[len(apiKey)-4:]
|
|
|
|
err = store.WithTx(ctx, func(q *sqlite.Queries) error {
|
|
if _, err := q.UserKeys.Create(ctx, sqlite.UserKeyRecord{
|
|
KeyID: keyID,
|
|
OwnerSubjectID: req.SubjectID,
|
|
KeyFingerprint: fingerprint,
|
|
MaskedPreview: masked,
|
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
|
LogicalGroupID: strings.TrimSpace(req.LogicalGroupID),
|
|
AllowedModels: req.AllowedModels,
|
|
AdminStatus: "active",
|
|
QuotaStatus: "ok",
|
|
}); err != nil {
|
|
return fmt.Errorf("create key: %w", err)
|
|
}
|
|
if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{
|
|
EventID: generateKeyID(),
|
|
ActorSubjectID: req.SubjectID,
|
|
ActorRole: "user",
|
|
TargetKeyID: keyID,
|
|
Action: "create",
|
|
Result: "success",
|
|
Reason: "self service create via host " + hostRow.HostID,
|
|
}); err != nil {
|
|
return fmt.Errorf("audit create key: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return CreateUserKeyResponse{}, err
|
|
}
|
|
|
|
return CreateUserKeyResponse{
|
|
Key: UserKeyMeta{
|
|
KeyID: keyID,
|
|
MaskedPreview: masked,
|
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
|
LogicalGroupID: strings.TrimSpace(req.LogicalGroupID),
|
|
AllowedModels: req.AllowedModels,
|
|
AdminStatus: "active",
|
|
QuotaStatus: "ok",
|
|
},
|
|
PlaintextKey: apiKey,
|
|
}, 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("key %q not found", keyID)
|
|
}
|
|
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
|
|
},
|
|
resetFn: func(ctx context.Context, keyID, subjectID string) (ResetUserKeyResponse, error) {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("open store: %w", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
rec, err := store.UserKeys().GetByID(ctx, keyID)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("get key: %w", err)
|
|
}
|
|
if rec.OwnerSubjectID != subjectID && subjectID != "admin" {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("key %q not found", keyID)
|
|
}
|
|
windowStart := time.Now().UTC().Format("2006-01-02T00:00:00Z")
|
|
count, err := store.SubjectRateLimits().IncrementWindow(ctx, subjectID, "reset", windowStart)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("increment reset rate limit: %w", err)
|
|
}
|
|
if count > defaultKeyResetPerDay {
|
|
return ResetUserKeyResponse{}, &httpError{StatusCode: 429, Code: "rate_limited", Message: "reset key rate limit exceeded"}
|
|
}
|
|
|
|
// Re-resolve host access to get a fresh key
|
|
_, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("resolve host for %q: %w", rec.LogicalGroupID, err)
|
|
}
|
|
hostGroupID, err := resolveShadowHostGroupID(ctx, client, route)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err)
|
|
}
|
|
newPlaintext, err := ensureSubjectHasAccess(ctx, client, rec.OwnerSubjectID, hostGroupID)
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, fmt.Errorf("ensure access on reset for %q: %w", rec.LogicalGroupID, err)
|
|
}
|
|
|
|
hostFingerprint := "sha256:" + sha256Hex(newPlaintext)
|
|
masked := "sk-****" + newPlaintext[len(newPlaintext)-4:]
|
|
|
|
err = store.WithTx(ctx, func(q *sqlite.Queries) error {
|
|
if err := q.UserKeys.UpdateSecret(ctx, keyID, hostFingerprint, masked, "active"); err != nil {
|
|
return fmt.Errorf("reset key: %w", err)
|
|
}
|
|
if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{
|
|
EventID: generateKeyID(),
|
|
ActorSubjectID: subjectID,
|
|
ActorRole: "user",
|
|
TargetKeyID: keyID,
|
|
Action: "reset",
|
|
Result: "success",
|
|
Reason: "self service reset",
|
|
}); err != nil {
|
|
return fmt.Errorf("audit reset key: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return ResetUserKeyResponse{}, err
|
|
}
|
|
return ResetUserKeyResponse{PlaintextKey: newPlaintext, MaskedPreview: masked, AdminStatus: "active"}, 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()
|
|
|
|
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("key %q not found", keyID)
|
|
}
|
|
_, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID)
|
|
if err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("resolve host for pause %q: %w", rec.LogicalGroupID, err)
|
|
}
|
|
hostGroupID, err := resolveShadowHostGroupID(ctx, client, route)
|
|
if err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("resolve shadow group id for pause %q: %w", route.ShadowGroupID, err)
|
|
}
|
|
if err := client.PauseManagedSubscriptionAccess(ctx, rec.OwnerSubjectID, hostGroupID); err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("pause managed subscription access: %w", err)
|
|
}
|
|
err = store.WithTx(ctx, func(q *sqlite.Queries) error {
|
|
if err := q.UserKeys.UpdateStatus(ctx, keyID, "paused"); err != nil {
|
|
return fmt.Errorf("pause key: %w", err)
|
|
}
|
|
if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{
|
|
EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user",
|
|
TargetKeyID: keyID, Action: "pause", Result: "success", Reason: strings.TrimSpace(reason),
|
|
}); err != nil {
|
|
return fmt.Errorf("audit pause key: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return UserKeyMeta{}, err
|
|
}
|
|
return UserKeyMeta{KeyID: keyID, MaskedPreview: rec.MaskedPreview, 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()
|
|
|
|
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("key %q not found", keyID)
|
|
}
|
|
_, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID)
|
|
if err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("resolve host for resume %q: %w", rec.LogicalGroupID, err)
|
|
}
|
|
hostGroupID, err := resolveShadowHostGroupID(ctx, client, route)
|
|
if err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("resolve shadow group id for resume %q: %w", route.ShadowGroupID, err)
|
|
}
|
|
if err := client.ResumeManagedSubscriptionAccess(ctx, rec.OwnerSubjectID, hostGroupID); err != nil {
|
|
return UserKeyMeta{}, fmt.Errorf("resume managed subscription access: %w", err)
|
|
}
|
|
err = store.WithTx(ctx, func(q *sqlite.Queries) error {
|
|
if err := q.UserKeys.UpdateStatus(ctx, keyID, "active"); err != nil {
|
|
return fmt.Errorf("resume key: %w", err)
|
|
}
|
|
if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{
|
|
EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user",
|
|
TargetKeyID: keyID, Action: "resume", Result: "success", Reason: "self service resume",
|
|
}); err != nil {
|
|
return fmt.Errorf("audit resume key: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return UserKeyMeta{}, err
|
|
}
|
|
return UserKeyMeta{KeyID: keyID, MaskedPreview: rec.MaskedPreview, 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()
|
|
|
|
rec, err := store.UserKeys().GetByID(ctx, keyID)
|
|
if err != nil {
|
|
return fmt.Errorf("get key: %w", err)
|
|
}
|
|
if rec.OwnerSubjectID != subjectID && subjectID != "admin" {
|
|
return fmt.Errorf("key %q not found", keyID)
|
|
}
|
|
err = store.WithTx(ctx, func(q *sqlite.Queries) error {
|
|
if err := q.UserKeys.UpdateStatus(ctx, keyID, "retired"); err != nil {
|
|
if strings.Contains(err.Error(), sql.ErrNoRows.Error()) {
|
|
return fmt.Errorf("key %q not found", keyID)
|
|
}
|
|
return fmt.Errorf("retire key: %w", err)
|
|
}
|
|
if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{
|
|
EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user",
|
|
TargetKeyID: keyID, Action: "delete", Result: "success", Reason: "self service retire",
|
|
}); err != nil {
|
|
return fmt.Errorf("audit retire key: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
func sha256Hex(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:])
|
|
}
|