feat(identity): 支持用户和用户组批量管理

增加原子批量启用、禁用和删除接口及管理端多选操作,目标缺失时整批回滚。\n\n拆分管理端与 API Key 权限缓存并在弹窗保存后刷新候选;补齐失效规则一键清理样式、固定右侧操作列和 OpenAPI 契约。
This commit is contained in:
2026-08-03 15:43:49 +08:00
parent 7376d6fab6
commit ad8cdd525b
19 changed files with 1167 additions and 74 deletions
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maxIdentityBatchSize = 500
type IdentityBatchInput struct {
IDs []string `json:"ids"`
Action string `json:"action" enums:"enable,disable,delete"`
}
type IdentityBatchResult struct {
Action string `json:"action"`
RequestedCount int `json:"requestedCount"`
AffectedCount int `json:"affectedCount"`
IDs []string `json:"ids"`
}
func (s *Store) BatchGatewayUsers(ctx context.Context, input IdentityBatchInput) (IdentityBatchResult, error) {
input, err := normalizeIdentityBatchInput(input)
if err != nil {
return IdentityBatchResult{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return IdentityBatchResult{}, err
}
defer rollbackTransaction(tx)
locked, err := lockIdentityBatchTargets(ctx, tx, `
SELECT id::text
FROM gateway_users
WHERE id = ANY($1::uuid[])
AND deleted_at IS NULL
FOR UPDATE`, input.IDs)
if err != nil {
return IdentityBatchResult{}, err
}
if len(locked) != len(input.IDs) {
return IdentityBatchResult{}, ErrIdentityBatchTargetNotFound
}
switch input.Action {
case "enable":
_, err = tx.Exec(ctx, `
UPDATE gateway_users
SET status = 'active', updated_at = now()
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
case "disable":
_, err = tx.Exec(ctx, `
UPDATE gateway_users
SET status = 'disabled', updated_at = now()
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
case "delete":
_, err = tx.Exec(ctx, `
UPDATE gateway_users
SET deleted_at = now(),
status = 'deleted',
user_key = user_key || ':deleted:' || left(id::text, 8),
external_user_id = CASE WHEN source = 'oidc' THEN external_user_id ELSE NULL END,
email = NULL,
updated_at = now()
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
}
if err != nil {
return IdentityBatchResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return IdentityBatchResult{}, err
}
return identityBatchResult(input), nil
}
func (s *Store) BatchUserGroups(ctx context.Context, input IdentityBatchInput) (IdentityBatchResult, error) {
input, err := normalizeIdentityBatchInput(input)
if err != nil {
return IdentityBatchResult{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return IdentityBatchResult{}, err
}
defer rollbackTransaction(tx)
locked, err := lockIdentityBatchTargets(ctx, tx, `
SELECT id::text
FROM gateway_user_groups
WHERE id = ANY($1::uuid[])
FOR UPDATE`, input.IDs)
if err != nil {
return IdentityBatchResult{}, err
}
if len(locked) != len(input.IDs) {
return IdentityBatchResult{}, ErrIdentityBatchTargetNotFound
}
switch input.Action {
case "enable":
_, err = tx.Exec(ctx, `
UPDATE gateway_user_groups
SET status = 'active', updated_at = now()
WHERE id = ANY($1::uuid[])`, input.IDs)
case "disable":
_, err = tx.Exec(ctx, `
UPDATE gateway_user_groups
SET status = 'disabled', updated_at = now()
WHERE id = ANY($1::uuid[])`, input.IDs)
case "delete":
if _, err = tx.Exec(ctx, `
DELETE FROM gateway_access_rules
WHERE subject_type = 'user_group'
AND subject_id = ANY($1::uuid[])`, input.IDs); err == nil {
_, err = tx.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, input.IDs)
}
}
if err != nil {
return IdentityBatchResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return IdentityBatchResult{}, err
}
return identityBatchResult(input), nil
}
func normalizeIdentityBatchInput(input IdentityBatchInput) (IdentityBatchInput, error) {
input.Action = strings.ToLower(strings.TrimSpace(input.Action))
if input.Action != "enable" && input.Action != "disable" && input.Action != "delete" {
return IdentityBatchInput{}, ErrInvalidIdentityBatch
}
seen := make(map[string]bool, len(input.IDs))
ids := make([]string, 0, len(input.IDs))
for _, value := range input.IDs {
id := strings.TrimSpace(value)
parsed, err := uuid.Parse(id)
if err != nil {
return IdentityBatchInput{}, ErrInvalidIdentityBatch
}
id = parsed.String()
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 || len(ids) > maxIdentityBatchSize {
return IdentityBatchInput{}, ErrInvalidIdentityBatch
}
input.IDs = ids
return input, nil
}
func lockIdentityBatchTargets(ctx context.Context, tx pgx.Tx, query string, ids []string) ([]string, error) {
rows, err := tx.Query(ctx, query, ids)
if err != nil {
return nil, err
}
defer rows.Close()
locked := make([]string, 0, len(ids))
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
locked = append(locked, id)
}
return locked, rows.Err()
}
func identityBatchResult(input IdentityBatchInput) IdentityBatchResult {
return IdentityBatchResult{
Action: input.Action,
RequestedCount: len(input.IDs),
AffectedCount: len(input.IDs),
IDs: append([]string(nil), input.IDs...),
}
}
@@ -0,0 +1,140 @@
package store
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestNormalizeIdentityBatchInput(t *testing.T) {
id := uuid.NewString()
input, err := normalizeIdentityBatchInput(IdentityBatchInput{
Action: " DISABLE ",
IDs: []string{id, " " + id + " "},
})
if err != nil {
t.Fatalf("normalize identity batch: %v", err)
}
if input.Action != "disable" || len(input.IDs) != 1 || input.IDs[0] != id {
t.Fatalf("unexpected normalized batch: %+v", input)
}
for _, invalid := range []IdentityBatchInput{
{Action: "archive", IDs: []string{id}},
{Action: "enable", IDs: nil},
{Action: "delete", IDs: []string{"not-a-uuid"}},
} {
if _, err := normalizeIdentityBatchInput(invalid); !errors.Is(err, ErrInvalidIdentityBatch) {
t.Fatalf("invalid batch %+v error=%v", invalid, err)
}
}
}
func TestIdentityBatchOperationsAreAtomic(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity batch PostgreSQL integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity batch test database: %v", err)
}
defer db.Close()
suffix := strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "")
groupA, err := db.CreateUserGroup(ctx, UserGroupInput{GroupKey: "batch-a-" + suffix, Name: "Batch A", Source: "gateway", Status: "active"})
if err != nil {
t.Fatalf("create group A: %v", err)
}
groupB, err := db.CreateUserGroup(ctx, UserGroupInput{GroupKey: "batch-b-" + suffix, Name: "Batch B", Source: "gateway", Status: "active"})
if err != nil {
t.Fatalf("create group B: %v", err)
}
userA, err := db.CreateGatewayUser(ctx, GatewayUserInput{UserKey: "batch-user-a-" + suffix, Username: "batch-user-a-" + suffix, Source: "gateway", DefaultUserGroupID: groupA.ID, Status: "active"})
if err != nil {
t.Fatalf("create user A: %v", err)
}
userB, err := db.CreateGatewayUser(ctx, GatewayUserInput{UserKey: "batch-user-b-" + suffix, Username: "batch-user-b-" + suffix, Source: "gateway", DefaultUserGroupID: groupB.ID, Status: "active"})
if err != nil {
t.Fatalf("create user B: %v", err)
}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_access_rules WHERE subject_id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID})
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE id = ANY($1::uuid[])`, []string{userA.ID, userB.ID})
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID})
})
if _, err := db.CreateAccessRule(ctx, AccessRuleInput{
SubjectType: "user_group", SubjectID: groupA.ID,
ResourceType: "platform_model", ResourceID: uuid.NewString(), Effect: "deny", Status: "active",
}); err != nil {
t.Fatalf("create group access rule: %v", err)
}
result, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userA.ID, userB.ID}, Action: "disable"})
if err != nil || result.AffectedCount != 2 || result.RequestedCount != 2 {
t.Fatalf("disable users result=%+v err=%v", result, err)
}
assertIdentityStatuses(t, ctx, db, "gateway_users", []string{userA.ID, userB.ID}, "disabled")
if _, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userB.ID}, Action: "enable"}); err != nil {
t.Fatalf("enable users: %v", err)
}
assertIdentityStatuses(t, ctx, db, "gateway_users", []string{userA.ID, userB.ID}, "active")
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, uuid.NewString()}, Action: "disable"}); !errors.Is(err, ErrIdentityBatchTargetNotFound) {
t.Fatalf("missing group batch error=%v", err)
}
assertIdentityStatuses(t, ctx, db, "gateway_user_groups", []string{groupA.ID}, "active")
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, groupB.ID}, Action: "disable"}); err != nil {
t.Fatalf("disable groups: %v", err)
}
assertIdentityStatuses(t, ctx, db, "gateway_user_groups", []string{groupA.ID, groupB.ID}, "disabled")
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, groupB.ID}, Action: "delete"}); err != nil {
t.Fatalf("delete groups: %v", err)
}
var groupCount, groupRuleCount, usersWithDeletedGroup int
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID}).Scan(&groupCount); err != nil {
t.Fatalf("count deleted groups: %v", err)
}
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rules WHERE subject_type = 'user_group' AND subject_id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID}).Scan(&groupRuleCount); err != nil {
t.Fatalf("count deleted group rules: %v", err)
}
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_users WHERE id = ANY($1::uuid[]) AND default_user_group_id IS NOT NULL`, []string{userA.ID, userB.ID}).Scan(&usersWithDeletedGroup); err != nil {
t.Fatalf("count stale default groups: %v", err)
}
if groupCount != 0 || groupRuleCount != 0 || usersWithDeletedGroup != 0 {
t.Fatalf("group batch delete left data: groups=%d rules=%d userRefs=%d", groupCount, groupRuleCount, usersWithDeletedGroup)
}
if _, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userB.ID}, Action: "delete"}); err != nil {
t.Fatalf("delete users: %v", err)
}
var deletedUsers int
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_users WHERE id = ANY($1::uuid[]) AND status = 'deleted' AND deleted_at IS NOT NULL`, []string{userA.ID, userB.ID}).Scan(&deletedUsers); err != nil {
t.Fatalf("count deleted users: %v", err)
}
if deletedUsers != 2 {
t.Fatalf("soft-deleted users=%d, want 2", deletedUsers)
}
}
func assertIdentityStatuses(t *testing.T, ctx context.Context, db *Store, table string, ids []string, want string) {
t.Helper()
query := `SELECT COUNT(*) FROM ` + table + ` WHERE id = ANY($1::uuid[]) AND status = $2`
var count int
if err := db.pool.QueryRow(ctx, query, ids, want).Scan(&count); err != nil {
t.Fatalf("read %s statuses: %v", table, err)
}
if count != len(ids) {
t.Fatalf("%s status %s count=%d, want %d", table, want, count, len(ids))
}
}
+2
View File
@@ -66,6 +66,8 @@ var (
ErrInvalidCredentials = errors.New("invalid account or password")
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
ErrInvalidIdentityBatch = errors.New("identity batch action or ids are invalid")
ErrIdentityBatchTargetNotFound = errors.New("one or more identity batch targets were not found")
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
ErrLocalUserRequired = errors.New("local gateway user is required")