feat: refine api key permissions and admin routes

This commit is contained in:
2026-05-10 23:22:26 +08:00
parent 0fc23d7eb8
commit d86651ff55
23 changed files with 1683 additions and 532 deletions
+144 -1
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -66,6 +67,35 @@ ORDER BY resource_type ASC, priority ASC, subject_type ASC, created_at DESC`)
return items, rows.Err()
}
func (s *Store) ListAPIKeyAccessRules(ctx context.Context, user *auth.User) ([]AccessRule, error) {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
return nil, ErrLocalUserRequired
}
rows, err := s.pool.Query(ctx, `
SELECT `+apiKeyAccessRuleColumns+`
FROM gateway_access_rules ar
JOIN gateway_api_keys k ON k.id = ar.subject_id
WHERE ar.subject_type = 'api_key'
AND k.gateway_user_id = $1::uuid
AND k.deleted_at IS NULL
ORDER BY ar.resource_type ASC, ar.priority ASC, ar.created_at DESC`, gatewayUserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]AccessRule, 0)
for rows.Next() {
item, err := scanAccessRule(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) CreateAccessRule(ctx context.Context, input AccessRuleInput) (AccessRule, error) {
input = normalizeAccessRuleInput(input)
conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions))
@@ -191,6 +221,38 @@ DO UPDATE SET priority = EXCLUDED.priority,
return s.ListAccessRules(ctx)
}
func (s *Store) BatchAPIKeyAccessRules(ctx context.Context, input AccessRuleBatchInput, user *auth.User) ([]AccessRule, error) {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
return nil, ErrLocalUserRequired
}
input = normalizeAccessRuleBatchInput(input)
if input.SubjectType != "api_key" {
return nil, pgx.ErrNoRows
}
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM gateway_api_keys
WHERE id = $1::uuid
AND gateway_user_id = $2::uuid
AND deleted_at IS NULL
)`, input.SubjectID, gatewayUserID).Scan(&exists); err != nil {
return nil, err
}
if !exists {
return nil, pgx.ErrNoRows
}
if err := s.ensureAPIKeyAccessRuleResourcesAllowed(ctx, user, input.UpsertResources); err != nil {
return nil, err
}
if _, err := s.BatchAccessRules(ctx, input); err != nil {
return nil, err
}
return s.ListAPIKeyAccessRules(ctx, user)
}
func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) {
if len(candidates) == 0 {
return candidates, nil
@@ -221,6 +283,10 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us
}
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
accessUser, err := s.resolveCurrentAccessUser(ctx, user)
if err != nil {
return nil, err
}
models, err := s.ListModels(ctx)
if err != nil {
return nil, err
@@ -241,7 +307,80 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use
enabled = append(enabled, model)
}
}
return s.filterPlatformModelsByAccessRules(ctx, user, enabled)
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled)
}
func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error {
resources = dedupeAccessRuleResources(resources)
if len(resources) == 0 {
return nil
}
allowed, err := s.accessibleAccessRuleResources(ctx, user)
if err != nil {
return err
}
for _, resource := range resources {
if !allowed[resource.ResourceType+":"+resource.ResourceID] {
return ErrAccessRuleResourceDenied
}
}
return nil
}
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
models, err := s.ListAccessiblePlatformModels(ctx, user)
if err != nil {
return nil, err
}
allowed := map[string]bool{}
for _, model := range models {
allowed["platform:"+model.PlatformID] = true
allowed["platform_model:"+model.ID] = true
if model.BaseModelID != "" {
allowed["base_model:"+model.BaseModelID] = true
}
}
return allowed, nil
}
func (s *Store) resolveCurrentAccessUser(ctx context.Context, user *auth.User) (*auth.User, error) {
if user == nil {
return nil, nil
}
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
return user, nil
}
next := *user
var userGroupID string
var err error
if strings.TrimSpace(user.APIKeyID) != "" {
err = s.pool.QueryRow(ctx, `
SELECT COALESCE(k.user_group_id::text, u.default_user_group_id::text, '')
FROM gateway_users u
JOIN gateway_api_keys k ON k.gateway_user_id = u.id
WHERE u.id = $1::uuid
AND k.id = $2::uuid
AND u.status = 'active'
AND u.deleted_at IS NULL
AND k.status = 'active'
AND k.deleted_at IS NULL`, gatewayUserID, user.APIKeyID).Scan(&userGroupID)
} else {
err = s.pool.QueryRow(ctx, `
SELECT COALESCE(default_user_group_id::text, '')
FROM gateway_users
WHERE id = $1::uuid
AND status = 'active'
AND deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID)
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &next, nil
}
return nil, err
}
next.UserGroupID = userGroupID
return &next, nil
}
func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
@@ -432,6 +571,10 @@ const accessRuleColumns = `
id::text, subject_type, subject_id::text, resource_type, resource_id::text, effect,
priority, min_permission_level, conditions, metadata, status, created_at, updated_at`
const apiKeyAccessRuleColumns = `
ar.id::text, ar.subject_type, ar.subject_id::text, ar.resource_type, ar.resource_id::text, ar.effect,
ar.priority, ar.min_permission_level, ar.conditions, ar.metadata, ar.status, ar.created_at, ar.updated_at`
func scanAccessRule(row scanner) (AccessRule, error) {
var item AccessRule
var conditions []byte
+13 -2
View File
@@ -258,14 +258,25 @@ RETURNING `+userGroupColumns,
}
func (s *Store) DeleteUserGroup(ctx context.Context, id string) error {
result, err := s.pool.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
DELETE FROM gateway_access_rules
WHERE subject_type = 'user_group' AND subject_id = $1::uuid`, id); err != nil {
return err
}
result, err := tx.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
return tx.Commit(ctx)
}
const tenantColumns = `
+31 -6
View File
@@ -48,13 +48,27 @@ func (s *Store) ReplacePlatformModels(ctx context.Context, platformID string, in
}
if len(keptIDs) == 0 {
if _, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE platform_id = $1::uuid`, platformID); err != nil {
if _, err := tx.Exec(ctx, `
WITH deleted AS (
DELETE FROM platform_models
WHERE platform_id = $1::uuid
RETURNING id
)
DELETE FROM gateway_access_rules
WHERE resource_type = 'platform_model'
AND resource_id IN (SELECT id FROM deleted)`, platformID); err != nil {
return nil, err
}
} else if _, err := tx.Exec(ctx, `
DELETE FROM platform_models
WHERE platform_id = $1::uuid
AND NOT (id::text = ANY($2::text[]))`, platformID, keptIDs); err != nil {
WITH deleted AS (
DELETE FROM platform_models
WHERE platform_id = $1::uuid
AND NOT (id::text = ANY($2::text[]))
RETURNING id
)
DELETE FROM gateway_access_rules
WHERE resource_type = 'platform_model'
AND resource_id IN (SELECT id FROM deleted)`, platformID, keptIDs); err != nil {
return nil, err
}
@@ -224,14 +238,25 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
}
func (s *Store) DeletePlatformModel(ctx context.Context, id string) error {
result, err := s.pool.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
if _, err := tx.Exec(ctx, `
DELETE FROM gateway_access_rules
WHERE resource_type = 'platform_model' AND resource_id = $1::uuid`, id); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) {
+84 -11
View File
@@ -22,12 +22,13 @@ type Store struct {
}
var (
ErrInvalidCredentials = errors.New("invalid account or password")
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
ErrLocalUserRequired = errors.New("local gateway user is required")
ErrProtectedDefault = errors.New("protected default resource cannot be deleted")
ErrUserAlreadyExists = errors.New("user already exists")
ErrWeakPassword = errors.New("password must be at least 8 characters")
ErrInvalidCredentials = errors.New("invalid account or password")
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
ErrLocalUserRequired = errors.New("local gateway user is required")
ErrProtectedDefault = errors.New("protected default resource cannot be deleted")
ErrUserAlreadyExists = errors.New("user already exists")
ErrWeakPassword = errors.New("password must be at least 8 characters")
)
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
@@ -102,6 +103,7 @@ type APIKey struct {
TenantKey string `json:"tenantKey,omitempty"`
UserID string `json:"userId,omitempty"`
KeyPrefix string `json:"keyPrefix"`
Secret string `json:"secret,omitempty"`
Name string `json:"name"`
Scopes []string `json:"scopes,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
@@ -627,14 +629,45 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C
}
func (s *Store) DeletePlatform(ctx context.Context, id string) error {
result, err := s.pool.Exec(ctx, `DELETE FROM integration_platforms WHERE id = $1::uuid`, id)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `SELECT id::text FROM platform_models WHERE platform_id = $1::uuid`, id)
if err != nil {
return err
}
modelIDs := make([]string, 0)
for rows.Next() {
var modelID string
if err := rows.Scan(&modelID); err != nil {
rows.Close()
return err
}
modelIDs = append(modelIDs, modelID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
result, err := tx.Exec(ctx, `DELETE FROM integration_platforms WHERE id = $1::uuid`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
if _, err := tx.Exec(ctx, `
DELETE FROM gateway_access_rules
WHERE (resource_type = 'platform' AND resource_id = $1::uuid)
OR (resource_type = 'platform_model' AND resource_id::text = ANY($2::text[]))`, id, modelIDs); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) ListModels(ctx context.Context) ([]PlatformModel, error) {
@@ -962,7 +995,11 @@ SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
key_prefix, name, scopes, COALESCE(user_group_id::text, ''),
rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''),
COALESCE(last_used_at::text, ''), created_at, updated_at
COALESCE(last_used_at::text, ''), created_at, updated_at,
CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > now())
THEN COALESCE(key_secret, '')
ELSE ''
END
FROM gateway_api_keys
WHERE gateway_user_id = $1::uuid AND deleted_at IS NULL
ORDER BY created_at DESC`, gatewayUserID)
@@ -973,7 +1010,8 @@ ORDER BY created_at DESC`, gatewayUserID)
items := make([]APIKey, 0)
for rows.Next() {
item, err := scanAPIKey(rows)
var secret string
item, err := scanAPIKeyWithSecret(rows, &secret)
if err != nil {
return nil, err
}
@@ -1155,13 +1193,45 @@ RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text
return item, nil
}
func (s *Store) DeleteAPIKey(ctx context.Context, apiKeyID string, user *auth.User) error {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
return ErrLocalUserRequired
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `
UPDATE gateway_api_keys
SET status = 'revoked',
key_secret = NULL,
deleted_at = now(),
updated_at = now()
WHERE id = $1::uuid AND gateway_user_id = $2::uuid AND deleted_at IS NULL`, apiKeyID, gatewayUserID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
if _, err := tx.Exec(ctx, `
DELETE FROM gateway_access_rules
WHERE subject_type = 'api_key' AND subject_id = $1::uuid`, apiKeyID); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) VerifyLocalAPIKey(ctx context.Context, secret string) (*auth.User, error) {
prefix := apiKeyPrefix(secret)
if prefix == "" {
return nil, auth.ErrUnauthorized
}
rows, err := s.pool.Query(ctx, `
SELECT k.id::text, k.key_hash, k.key_prefix, k.name, COALESCE(k.user_group_id::text, ''),
SELECT k.id::text, k.key_hash, k.key_prefix, k.name, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''),
u.id::text, u.username, u.roles, COALESCE(u.gateway_tenant_id::text, ''),
COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, '')
FROM gateway_api_keys k
@@ -1722,6 +1792,9 @@ func scanAPIKeyWithSecret(scanner apiKeyScanner, secret *string) (APIKey, error)
item.Scopes = decodeStringArray(scopesBytes)
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
item.QuotaPolicy = decodeObject(quotaPolicy)
if secret != nil {
item.Secret = *secret
}
return item, nil
}