diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 0557640..6624ddb 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -19,7 +19,7 @@ "BearerAuth": [] } ], - "description": "管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。", + "description": "管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 allow 时仅允许白名单,deny 始终优先。", "produces": [ "application/json" ], @@ -60,7 +60,7 @@ "BearerAuth": [] } ], - "description": "管理端创建一条访问控制规则。", + "description": "管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先。", "consumes": [ "application/json" ], @@ -129,7 +129,7 @@ "BearerAuth": [] } ], - "description": "管理端为同一主体批量新增、更新或删除资源访问规则。", + "description": "管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。", "consumes": [ "application/json" ], @@ -4844,6 +4844,75 @@ } } }, + "/api/admin/user-groups/batch": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "批量操作用户组", + "parameters": [ + { + "description": "用户组批量操作", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/store.IdentityBatchInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.IdentityBatchResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/admin/user-groups/{groupID}": { "delete": { "security": [ @@ -5089,6 +5158,75 @@ } } }, + "/api/admin/users/batch": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "批量操作用户", + "parameters": [ + { + "description": "用户批量操作", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/store.IdentityBatchInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.IdentityBatchResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/admin/users/{userID}": { "delete": { "security": [ @@ -5600,7 +5738,7 @@ "BearerAuth": [] } ], - "description": "返回当前本地用户可管理的 API Key 访问规则。", + "description": "返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则。", "produces": [ "application/json" ], @@ -5649,7 +5787,7 @@ "BearerAuth": [] } ], - "description": "当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源。", + "description": "当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow 后仅允许命中项。", "consumes": [ "application/json" ], @@ -5718,7 +5856,7 @@ "BearerAuth": [] } ], - "description": "按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。", + "description": "按当前用户自身的租户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则。", "produces": [ "application/json" ], @@ -5817,7 +5955,7 @@ "BearerAuth": [] } ], - "description": "返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。", + "description": "返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 allow/deny 不缩减候选,仅作为已有规则有效性诊断返回。", "produces": [ "application/json" ], @@ -7725,7 +7863,7 @@ "BearerAuth": [] } ], - "description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。", + "description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值。", "produces": [ "application/json" ], @@ -7823,7 +7961,7 @@ "BearerAuth": [] } ], - "description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。", + "description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值。", "produces": [ "application/json" ], @@ -10345,7 +10483,7 @@ "BearerAuth": [] } ], - "description": "按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。", + "description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值。", "produces": [ "application/json" ], @@ -11217,6 +11355,29 @@ } } }, + "httpapi.IdentityBatchResponse": { + "type": "object", + "properties": { + "action": { + "type": "string", + "example": "disable" + }, + "affectedCount": { + "type": "integer", + "example": 2 + }, + "ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "example": 2 + } + } + }, "httpapi.ImageVectorizeRequest": { "type": "object", "properties": { @@ -15263,6 +15424,25 @@ } } }, + "store.IdentityBatchInput": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "enable", + "disable", + "delete" + ] + }, + "ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "store.LocalLoginInput": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 71c9ddd..83e7061 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -586,6 +586,22 @@ definitions: example: easyai-ai-gateway type: string type: object + httpapi.IdentityBatchResponse: + properties: + action: + example: disable + type: string + affectedCount: + example: 2 + type: integer + ids: + items: + type: string + type: array + requestedCount: + example: 2 + type: integer + type: object httpapi.ImageVectorizeRequest: properties: cleanupLevel: @@ -3353,6 +3369,19 @@ definitions: transactionType: type: string type: object + store.IdentityBatchInput: + properties: + action: + enum: + - enable + - disable + - delete + type: string + ids: + items: + type: string + type: array + type: object store.LocalLoginInput: properties: account: @@ -4330,7 +4359,8 @@ info: paths: /api/admin/access-rules: get: - description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。 + description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 + allow 时仅允许白名单,deny 始终优先。 produces: - application/json responses: @@ -4358,7 +4388,7 @@ paths: post: consumes: - application/json - description: 管理端创建一条访问控制规则。 + description: 管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先。 parameters: - description: 访问规则请求 in: body @@ -4489,7 +4519,7 @@ paths: post: consumes: - application/json - description: 管理端为同一主体批量新增、更新或删除资源访问规则。 + description: 管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。 parameters: - description: 访问规则批量请求 in: body @@ -7508,6 +7538,50 @@ paths: summary: 更新用户组 tags: - identity + /api/admin/user-groups/batch: + post: + consumes: + - application/json + description: 管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。 + parameters: + - description: 用户组批量操作 + in: body + name: input + required: true + schema: + $ref: '#/definitions/store.IdentityBatchInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.IdentityBatchResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 批量操作用户组 + tags: + - identity /api/admin/users: get: description: 管理端返回网关用户列表及钱包摘要。 @@ -7763,6 +7837,50 @@ paths: summary: 充值用户钱包余额 tags: - billing + /api/admin/users/batch: + post: + consumes: + - application/json + description: 管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。 + parameters: + - description: 用户批量操作 + in: body + name: input + required: true + schema: + $ref: '#/definitions/store.IdentityBatchInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.IdentityBatchResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 批量操作用户 + tags: + - identity /api/playground/api-keys: get: description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。 @@ -7938,7 +8056,8 @@ paths: - api-keys /api/v1/api-keys/{apiKeyID}/assignable-models: get: - description: 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。 + description: 返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 + allow/deny 不缩减候选,仅作为已有规则有效性诊断返回。 parameters: - description: API Key ID in: path @@ -8057,7 +8176,7 @@ paths: - api-keys /api/v1/api-keys/access-rules: get: - description: 返回当前本地用户可管理的 API Key 访问规则。 + description: 返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则。 produces: - application/json responses: @@ -8090,7 +8209,8 @@ paths: post: consumes: - application/json - description: 当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源。 + description: 当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow + 后仅允许命中项。 parameters: - description: API Key 访问规则批量请求,subjectType 必须为 api_key in: body @@ -8133,7 +8253,7 @@ paths: /api/v1/api-keys/assignable-models: get: deprecated: true - description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。 + description: 按当前用户自身的租户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则。 produces: - application/json responses: @@ -9277,7 +9397,8 @@ paths: - agent-resources /api/v1/platform-models: get: - description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。 + description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API + 调用的平台来源;其他主体规则不参与求值。 parameters: - description: 模型可选场景;不传时保持原有行为 enum: @@ -9339,7 +9460,8 @@ paths: - playground /api/v1/playground/models: get: - description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。 + description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API + 调用的平台来源;其他主体规则不参与求值。 parameters: - description: 模型可选场景;不传时保持原有行为 enum: @@ -10974,7 +11096,7 @@ paths: - static /v1/models: get: - description: 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。 + description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值。 produces: - application/json responses: diff --git a/apps/api/internal/httpapi/identity_admin_handlers.go b/apps/api/internal/httpapi/identity_admin_handlers.go index fa9af43..42af5bc 100644 --- a/apps/api/internal/httpapi/identity_admin_handlers.go +++ b/apps/api/internal/httpapi/identity_admin_handlers.go @@ -2,12 +2,83 @@ package httpapi import ( "encoding/json" + "errors" "net/http" "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) +// batchGatewayUsers godoc +// @Summary 批量操作用户 +// @Description 管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。 +// @Tags identity +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param input body store.IdentityBatchInput true "用户批量操作" +// @Success 200 {object} IdentityBatchResponse +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/admin/users/batch [post] +func (s *Server) batchGatewayUsers(w http.ResponseWriter, r *http.Request) { + var input store.IdentityBatchInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + result, err := s.store.BatchGatewayUsers(r.Context(), input) + if err != nil { + writeIdentityBatchError(w, s, err, "users") + return + } + writeJSON(w, http.StatusOK, result) +} + +// batchUserGroups godoc +// @Summary 批量操作用户组 +// @Description 管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。 +// @Tags identity +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param input body store.IdentityBatchInput true "用户组批量操作" +// @Success 200 {object} IdentityBatchResponse +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/admin/user-groups/batch [post] +func (s *Server) batchUserGroups(w http.ResponseWriter, r *http.Request) { + var input store.IdentityBatchInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + result, err := s.store.BatchUserGroups(r.Context(), input) + if err != nil { + writeIdentityBatchError(w, s, err, "user groups") + return + } + writeJSON(w, http.StatusOK, result) +} + +func writeIdentityBatchError(w http.ResponseWriter, s *Server, err error, target string) { + switch { + case errors.Is(err, store.ErrInvalidIdentityBatch): + writeError(w, http.StatusBadRequest, "action must be enable, disable or delete and ids must contain 1 to 500 UUIDs") + case errors.Is(err, store.ErrIdentityBatchTargetNotFound): + writeError(w, http.StatusNotFound, "one or more "+target+" were not found") + default: + s.logger.Error("batch identity operation failed", "target", target, "error", err) + writeError(w, http.StatusInternalServerError, "batch "+target+" operation failed") + } +} + // createTenant godoc // @Summary 创建租户 // @Description 管理端创建网关租户,tenantKey 和 name 必填。 diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 6ec469b..c46afc2 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -144,6 +144,13 @@ type UserGroupListResponse struct { Items []store.UserGroup `json:"items"` } +type IdentityBatchResponse struct { + Action string `json:"action" example:"disable"` + RequestedCount int `json:"requestedCount" example:"2"` + AffectedCount int `json:"affectedCount" example:"2"` + IDs []string `json:"ids"` +} + type AccessRuleListResponse struct { Items []store.AccessRule `json:"items"` } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 80bc76e..43f156d 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -197,6 +197,7 @@ func NewServerWithStores( mux.Handle("DELETE /api/admin/tenants/{tenantID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteTenant))) mux.Handle("GET /api/admin/users", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUsers))) mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser))) + mux.Handle("POST /api/admin/users/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchGatewayUsers))) mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser))) mux.Handle("PATCH /api/admin/users/{userID}/wallet", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.setUserWalletBalance))) mux.Handle("POST /api/admin/users/{userID}/wallet/recharge", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rechargeUserWalletBalance))) @@ -204,6 +205,7 @@ func NewServerWithStores( mux.Handle("GET /api/admin/audit-logs", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAuditLogs))) mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups))) mux.Handle("POST /api/admin/user-groups", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createUserGroup))) + mux.Handle("POST /api/admin/user-groups/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchUserGroups))) mux.Handle("PATCH /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup))) mux.Handle("DELETE /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup))) mux.Handle("GET /api/admin/access-rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAccessRules))) diff --git a/apps/api/internal/store/identity_batch.go b/apps/api/internal/store/identity_batch.go new file mode 100644 index 0000000..47c68f0 --- /dev/null +++ b/apps/api/internal/store/identity_batch.go @@ -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...), + } +} diff --git a/apps/api/internal/store/identity_batch_test.go b/apps/api/internal/store/identity_batch_test.go new file mode 100644 index 0000000..f89d934 --- /dev/null +++ b/apps/api/internal/store/identity_batch_test.go @@ -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)) + } +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index e2af7f6..03c62db 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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") diff --git a/apps/web/src/App.data-keys.test.ts b/apps/web/src/App.data-keys.test.ts new file mode 100644 index 0000000..205b22c --- /dev/null +++ b/apps/web/src/App.data-keys.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { dataKeysForRoute } from './App'; + +describe('access-rule route data isolation', () => { + it('uses independent cache keys for workspace and admin access rules', () => { + expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).toContain('apiKeyAccessRules'); + expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).not.toContain('adminAccessRules'); + + expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).toContain('adminAccessRules'); + expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).not.toContain('apiKeyAccessRules'); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 73378bc..1810e11 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,6 +24,7 @@ import type { GatewayUser, GatewayWalletAccount, GatewayWalletTransaction, + IdentityBatchAction, IntegrationPlatform, ModelCatalogResponse, ModelRateLimitStatus, @@ -42,6 +43,8 @@ import type { import { batchAccessRules, batchApiKeyAccessRules, + batchGatewayUsers, + batchUserGroups, createAccessRule, createApiKey, createFileStorageChannel, @@ -179,7 +182,7 @@ import type { WorkspaceSection, } from './types'; -type DataKey = +export type DataKey = | 'health' | 'currentUser' | 'currentUserGroups' @@ -210,7 +213,8 @@ type DataKey = | 'tasks' | 'wallet' | 'walletTransactions' - | 'accessRules' + | 'adminAccessRules' + | 'apiKeyAccessRules' | 'auditLogs' | 'apiKeys'; @@ -251,7 +255,8 @@ export function App() { const [pricingRuleSets, setPricingRuleSets] = useState([]); const [runnerPolicy, setRunnerPolicy] = useState(null); const [runtimePolicySets, setRuntimePolicySets] = useState([]); - const [accessRules, setAccessRules] = useState([]); + const [adminAccessRules, setAdminAccessRules] = useState([]); + const [apiKeyAccessRules, setApiKeyAccessRules] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [rateLimitWindows, setRateLimitWindows] = useState([]); const [modelRateLimits, setModelRateLimits] = useState([]); @@ -424,13 +429,13 @@ export function App() { { label: 'Provider', value: activeProviders || providers.length, tone: 'amber' }, { label: '定价规则', value: pricingRules.length, tone: 'cyan' }, { label: '运行策略', value: runtimePolicySets.length, tone: 'slate' }, - { label: '访问规则', value: accessRules.length, tone: 'amber' }, + { label: '访问规则', value: adminAccessRules.length, tone: 'amber' }, { label: '限流窗口', value: activeRateWindows, tone: 'rose' }, ]; - }, [accessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]); + }, [adminAccessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]); const data = useMemo(() => ({ - accessRules, + accessRules: activePage === 'workspace' ? apiKeyAccessRules : adminAccessRules, adminTasks, auditLogs, apiKeys, @@ -461,7 +466,7 @@ export function App() { users, walletAccounts, walletTransactions, - }), [accessRules, adminTasks, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]); + }), [activePage, adminAccessRules, adminTasks, apiKeyAccessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -653,10 +658,11 @@ export function App() { loadedTransactionQueryKeyRef.current = requestKey; return; } - case 'accessRules': - setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys' - ? listApiKeyAccessRules(nextToken) - : listAccessRules(nextToken))).items); + case 'adminAccessRules': + setAdminAccessRules((await listAccessRules(nextToken)).items); + return; + case 'apiKeyAccessRules': + setApiKeyAccessRules((await listApiKeyAccessRules(nextToken)).items); return; case 'auditLogs': setAuditLogs((await listAuditLogs(nextToken)).items); @@ -957,6 +963,28 @@ export function App() { } } + async function batchOperateUsers(ids: string[], action: IdentityBatchAction) { + setCoreState('loading'); + setCoreMessage(''); + try { + const response = await batchGatewayUsers(token, { ids, action }); + const affected = new Set(response.ids); + if (action === 'delete') { + setUsers((current) => current.filter((user) => !affected.has(user.id))); + } else { + const status = action === 'enable' ? 'active' : 'disabled'; + setUsers((current) => current.map((user) => affected.has(user.id) ? { ...user, status } : user)); + } + invalidateDataKeys('playgroundModels'); + setCoreState('ready'); + setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户。`); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '批量操作用户失败'); + throw err; + } + } + async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) { setCoreState('loading'); setCoreMessage(''); @@ -981,6 +1009,7 @@ export function App() { setUserGroups((current) => current.filter((group) => group.id !== groupId)); setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); + setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && rule.subjectId === groupId))); invalidateDataKeys('modelCatalog', 'playgroundModels'); setCoreState('ready'); setCoreMessage('用户组已删除。'); @@ -991,13 +1020,42 @@ export function App() { } } + async function batchOperateUserGroups(ids: string[], action: IdentityBatchAction) { + setCoreState('loading'); + setCoreMessage(''); + try { + const response = await batchUserGroups(token, { ids, action }); + const affected = new Set(response.ids); + if (action === 'delete') { + setUserGroups((current) => current.filter((group) => !affected.has(group.id))); + setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId && affected.has(tenant.defaultUserGroupId) + ? { ...tenant, defaultUserGroupId: undefined } + : tenant)); + setUsers((current) => current.map((user) => user.defaultUserGroupId && affected.has(user.defaultUserGroupId) + ? { ...user, defaultUserGroupId: undefined } + : user)); + setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && affected.has(rule.subjectId)))); + } else { + const status = action === 'enable' ? 'active' : 'disabled'; + setUserGroups((current) => current.map((group) => affected.has(group.id) ? { ...group, status } : group)); + } + invalidateDataKeys('modelCatalog', 'playgroundModels'); + setCoreState('ready'); + setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户组。`); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '批量操作用户组失败'); + throw err; + } + } + async function removeAPIKey(apiKeyId: string) { setCoreState('loading'); setCoreMessage(''); try { await deleteApiKey(token, apiKeyId); setApiKeys((current) => current.filter((item) => item.id !== apiKeyId)); - setAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId))); + setApiKeyAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId))); setApiKeySecretsById((current) => { const next = { ...current }; delete next[apiKeyId]; @@ -1034,7 +1092,7 @@ export function App() { setCoreMessage(''); try { const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); - setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); + setAdminAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); @@ -1050,7 +1108,7 @@ export function App() { setCoreMessage(''); try { await deleteAccessRule(token, ruleId); - setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); + setAdminAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限规则已删除。'); @@ -1066,7 +1124,7 @@ export function App() { setCoreMessage(''); try { const response = await batchAccessRules(token, input); - setAccessRules(response.items); + setAdminAccessRules(response.items); invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限已更新。'); @@ -1192,7 +1250,7 @@ export function App() { setCoreMessage(''); try { const response = await batchApiKeyAccessRules(token, input); - setAccessRules(response.items); + setApiKeyAccessRules(response.items); setCoreState('ready'); setCoreMessage('API Key 权限已更新。'); } catch (err) { @@ -1258,7 +1316,8 @@ export function App() { setPricingRuleSets([]); setRunnerPolicy(null); setRuntimePolicySets([]); - setAccessRules([]); + setAdminAccessRules([]); + setApiKeyAccessRules([]); setAuditLogs([]); setRateLimitWindows([]); setModelRateLimits([]); @@ -1466,6 +1525,7 @@ export function App() { transactionQuery={workspaceTransactionQuery} transactionTotal={walletTransactionTotal} onBatchAccessRules={batchSaveAPIKeyAccessRules} + onRefreshAccessRules={() => ensureData(['apiKeyAccessRules'], token, true)} onDeleteApiKey={removeAPIKey} onApiKeyFormChange={setApiKeyForm} onSectionChange={navigateWorkspaceSection} @@ -1517,6 +1577,8 @@ export function App() { onDeleteTenant={removeTenant} onDeleteUser={removeUser} onDeleteUserGroup={removeUserGroup} + onBatchUsers={batchOperateUsers} + onBatchUserGroups={batchOperateUserGroups} onSaveBaseModel={saveBaseModel} onResetAllBaseModels={resetAllBaseModelsToDefault} onResetBaseModel={resetBaseModelToDefault} @@ -1710,7 +1772,13 @@ function clampTransactionPageSize(value: number) { return Math.min(100, Math.max(1, normalized)); } -function dataKeysForRoute( +function identityBatchActionLabel(action: IdentityBatchAction) { + if (action === 'enable') return '启用'; + if (action === 'disable') return '禁用'; + return '删除'; +} + +export function dataKeysForRoute( activePage: PageKey, adminSection: AdminSection, workspaceSection: WorkspaceSection, @@ -1729,7 +1797,7 @@ function dataKeysForRoute( if (activePage === 'workspace') { if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys']; if (workspaceSection === 'billing') return ['wallet']; - if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules']; + if (workspaceSection === 'apiKeys') return ['apiKeys', 'apiKeyAccessRules']; if (workspaceSection === 'tasks') return ['tasks']; if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions']; return []; @@ -1738,7 +1806,7 @@ function dataKeysForRoute( if (activePage !== 'admin') return []; switch (adminSection) { case 'overview': - return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'accessRules']; + return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'adminAccessRules']; case 'globalModels': return ['providers']; case 'pricing': @@ -1760,7 +1828,7 @@ function dataKeysForRoute( case 'users': return ['users', 'tenants', 'userGroups']; case 'userGroups': - return ['userGroups', 'accessRules', 'platforms', 'models']; + return ['userGroups', 'adminAccessRules', 'platforms', 'models']; case 'auditLogs': return ['auditLogs']; case 'systemSettings': diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index f136006..a9d2274 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + batchGatewayUsers, + batchUserGroups, cancelIdentityPairing, connectSecurityEventTransmitter, createResponse, @@ -253,6 +255,33 @@ describe('API Key permission resources', () => { }); }); +describe('identity batch transports', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts explicit actions and selected ids to the user and user-group batch endpoints', async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ + action: 'disable', requestedCount: 2, affectedCount: 2, ids: ['id-a', 'id-b'], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))); + vi.stubGlobal('fetch', fetchMock); + + await batchGatewayUsers('manager-token', { action: 'disable', ids: ['id-a', 'id-b'] }); + await batchUserGroups('manager-token', { action: 'delete', ids: ['group-a'] }); + + const [userURL, userInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(userURL).toContain('/api/admin/users/batch'); + expect(userInit.method).toBe('POST'); + expect(JSON.parse(String(userInit.body))).toEqual({ action: 'disable', ids: ['id-a', 'id-b'] }); + const [groupURL, groupInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(groupURL).toContain('/api/admin/user-groups/batch'); + expect(JSON.parse(String(groupInit.body))).toEqual({ action: 'delete', ids: ['group-a'] }); + }); +}); + describe('Public Agent resources', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 895a97f..98d2639 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -39,6 +39,8 @@ import type { GatewayUser, GatewayUserUpsertRequest, GatewayWalletTransaction, + IdentityBatchRequest, + IdentityBatchResponse, IntegrationPlatform, ListResponse, ModelCatalogResponse, @@ -419,6 +421,17 @@ export async function deleteGatewayUser(token: string, userId: string): Promise< }); } +export async function batchGatewayUsers( + token: string, + input: IdentityBatchRequest, +): Promise { + return request('/api/admin/users/batch', { + body: input, + method: 'POST', + token, + }); +} + export async function listAuditLogs(token: string): Promise> { return request>('/api/admin/audit-logs', { token }); } @@ -454,6 +467,17 @@ export async function deleteUserGroup(token: string, groupId: string): Promise { + return request('/api/admin/user-groups/batch', { + body: input, + method: 'POST', + token, + }); +} + export async function listAccessRules(token: string): Promise> { return request>('/api/admin/access-rules', { token }); } diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 9eb3a26..64cf042 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -11,6 +11,7 @@ import type { GatewayTenantUpsertRequest, GatewayRunnerPolicyUpsertRequest, GatewayUserUpsertRequest, + IdentityBatchAction, IntegrationPlatform, PlatformDynamicPriorityUpdateRequest, PricingRuleSetUpsertRequest, @@ -73,6 +74,8 @@ export function AdminPage(props: { onDeleteTenant: (tenantId: string) => Promise; onDeleteUser: (userId: string) => Promise; onDeleteUserGroup: (groupId: string) => Promise; + onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise; + onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise; onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise; onResetAllBaseModels: () => Promise; onResetBaseModel: (baseModelId: string) => Promise; @@ -227,6 +230,8 @@ function identityPanelProps(props: { onDeleteTenant: (tenantId: string) => Promise; onDeleteUser: (userId: string) => Promise; onDeleteUserGroup: (groupId: string) => Promise; + onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise; + onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise; onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise; onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; @@ -240,6 +245,8 @@ function identityPanelProps(props: { onDeleteTenant: props.onDeleteTenant, onDeleteUser: props.onDeleteUser, onDeleteUserGroup: props.onDeleteUserGroup, + onBatchUsers: props.onBatchUsers, + onBatchUserGroups: props.onBatchUserGroups, onSaveTenant: props.onSaveTenant, onSaveUser: props.onSaveUser, onRechargeUserWalletBalance: props.onRechargeUserWalletBalance, diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index fa1afbf..8790f51 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -49,6 +49,7 @@ export function WorkspacePage(props: { transactionQuery: WorkspaceTransactionQuery; transactionTotal: number; onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; + onRefreshAccessRules: () => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSectionChange: (value: WorkspaceSection) => void; @@ -396,6 +397,7 @@ function ApiKeyPanel(props: { state: LoadState; token: string; onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; + onRefreshAccessRules: () => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise; @@ -413,6 +415,7 @@ function ApiKeyPanel(props: { const [policyDiagnostics, setPolicyDiagnostics] = useState([]); const [policyState, setPolicyState] = useState('idle'); const [policyError, setPolicyError] = useState(''); + const policyRequestIdRef = useRef(0); const selectedPolicyKey = useMemo( () => props.data.apiKeys.find((item) => item.id === policyApiKeyId), [policyApiKeyId, props.data.apiKeys], @@ -423,41 +426,46 @@ function ApiKeyPanel(props: { ); const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]); - useEffect(() => { - if (!policyApiKeyId) { - setPolicyModels([]); - setPolicyDiagnostics([]); - setPolicyState('idle'); - setPolicyError(''); - return; - } - let cancelled = false; + async function loadPolicy(apiKeyId: string) { + const requestId = ++policyRequestIdRef.current; setPolicyState('loading'); setPolicyError(''); - void listApiKeyAssignableModels(props.token, policyApiKeyId).then((response) => { - if (cancelled) return; + try { + const [response] = await Promise.all([ + listApiKeyAssignableModels(props.token, apiKeyId), + props.onRefreshAccessRules(), + ]); + if (requestId !== policyRequestIdRef.current) return; setPolicyModels(response.items); setPolicyDiagnostics(response.ruleDiagnostics); setPolicyState('ready'); - }).catch((error) => { - if (cancelled) return; + } catch (error) { + if (requestId !== policyRequestIdRef.current) return; setPolicyModels([]); setPolicyDiagnostics([]); setPolicyState('error'); setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败'); - }); - return () => { - cancelled = true; - }; - }, [policyApiKeyId, props.token]); + } + } + + function openPolicyDialog(item: GatewayApiKey) { + setPolicyApiKeyId(item.id); + void loadPolicy(item.id); + } + + function closePolicyDialog() { + policyRequestIdRef.current += 1; + setPolicyApiKeyId(''); + setPolicyModels([]); + setPolicyDiagnostics([]); + setPolicyState('idle'); + setPolicyError(''); + } async function savePolicyRules(input: GatewayAccessRuleBatchRequest) { await props.onBatchAccessRules(input); if (!policyApiKeyId) return; - const response = await listApiKeyAssignableModels(props.token, policyApiKeyId); - setPolicyModels(response.items); - setPolicyDiagnostics(response.ruleDiagnostics); - setPolicyState('ready'); + await loadPolicy(policyApiKeyId); } async function copyApiKey(item: GatewayApiKey) { @@ -572,7 +580,7 @@ function ApiKeyPanel(props: { - @@ -657,10 +665,10 @@ function ApiKeyPanel(props: { ariaLabel="维护 API Key 权限策略" bodyClassName="apiKeyPolicyDialogBody" className="apiKeyPolicyDialog" - footer={} + footer={} open={Boolean(selectedPolicyKey)} title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'} - onClose={() => setPolicyApiKeyId('')} + onClose={closePolicyDialog} onSubmit={(event) => event.preventDefault()} > {policyError &&

{policyError}

} diff --git a/apps/web/src/pages/admin/IdentityBatchToolbar.test.tsx b/apps/web/src/pages/admin/IdentityBatchToolbar.test.tsx new file mode 100644 index 0000000..9d43fae --- /dev/null +++ b/apps/web/src/pages/admin/IdentityBatchToolbar.test.tsx @@ -0,0 +1,24 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { IdentityBatchToolbar, identityBatchActionCopy } from './IdentityBatchToolbar'; +import { pruneSelectedIds, updateSelectedIds } from './IdentityManagementPanels'; + +describe('identity batch operations', () => { + it('renders enable, disable and delete actions with the current selection count', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('已选'); + expect(html).toContain('批量启用'); + expect(html).toContain('批量禁用'); + expect(html).toContain('批量删除'); + expect(identityBatchActionCopy('delete', '用户组', 3).description).toContain('3 个用户组'); + }); + + it('adds, removes and prunes selections after list updates', () => { + const selected = updateSelectedIds(new Set(['a']), 'b', true); + expect(Array.from(selected).sort()).toEqual(['a', 'b']); + expect(Array.from(updateSelectedIds(selected, 'a', false))).toEqual(['b']); + expect(Array.from(pruneSelectedIds(selected, ['b', 'c']))).toEqual(['b']); + }); +}); diff --git a/apps/web/src/pages/admin/IdentityBatchToolbar.tsx b/apps/web/src/pages/admin/IdentityBatchToolbar.tsx new file mode 100644 index 0000000..527e90d --- /dev/null +++ b/apps/web/src/pages/admin/IdentityBatchToolbar.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { Ban, CheckCircle2, Trash2 } from 'lucide-react'; +import type { IdentityBatchAction } from '@easyai-ai-gateway/contracts'; +import { Button, ConfirmDialog } from '../../components/ui'; + +export function IdentityBatchToolbar(props: { + entityLabel: string; + loading: boolean; + selectedCount: number; + onAction: (action: IdentityBatchAction) => Promise; +}) { + const [pendingAction, setPendingAction] = useState(null); + const disabled = props.loading || props.selectedCount === 0; + const copy = pendingAction ? identityBatchActionCopy(pendingAction, props.entityLabel, props.selectedCount) : null; + + async function confirm() { + if (!pendingAction) return; + await props.onAction(pendingAction); + setPendingAction(null); + } + + return ( + <> +
+ 已选 {props.selectedCount} +
+ + + +
+
+ setPendingAction(null)} + onConfirm={confirm} + /> + + ); +} + +export function identityBatchActionCopy(action: IdentityBatchAction, entityLabel: string, count: number) { + if (action === 'enable') { + return { + confirmLabel: '确认启用', + description: `选中的 ${count} 个${entityLabel}将恢复为 active 状态。`, + title: `批量启用${entityLabel}?`, + }; + } + if (action === 'disable') { + return { + confirmLabel: '确认禁用', + description: `选中的 ${count} 个${entityLabel}将被设为 disabled。`, + title: `批量禁用${entityLabel}?`, + }; + } + return { + confirmLabel: '确认批量删除', + description: `将删除选中的 ${count} 个${entityLabel},该操作不能从当前页面撤销。`, + title: `批量删除${entityLabel}?`, + }; +} diff --git a/apps/web/src/pages/admin/IdentityManagementPanels.tsx b/apps/web/src/pages/admin/IdentityManagementPanels.tsx index 3f5bd0c..506cae0 100644 --- a/apps/web/src/pages/admin/IdentityManagementPanels.tsx +++ b/apps/web/src/pages/admin/IdentityManagementPanels.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type FormEvent, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react'; import type { GatewayTenant, @@ -7,6 +7,7 @@ import type { GatewayUser, GatewayUserUpsertRequest, GatewayWalletAccount, + IdentityBatchAction, UserGroup, UserGroupUpsertRequest, WalletRechargeRequest, @@ -18,6 +19,7 @@ import { CardContent, CardHeader, CardTitle, + Checkbox, ConfirmDialog, FormDialog, Input, @@ -33,6 +35,7 @@ import { import type { ConsoleData } from '../../app-state'; import type { LoadState } from '../../types'; import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor'; +import { IdentityBatchToolbar } from './IdentityBatchToolbar'; import { quotaPolicyFromForm, quotaPolicySummary, @@ -244,8 +247,15 @@ export function UsersPanel(props: IdentityPanelProps) { const [pendingDeleteUser, setPendingDeleteUser] = useState(null); const [walletUser, setWalletUser] = useState(null); const [walletForm, setWalletForm] = useState(() => defaultWalletForm()); + const [selectedUserIds, setSelectedUserIds] = useState>(() => new Set()); const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]); + const allUsersSelected = props.data.users.length > 0 && props.data.users.every((user) => selectedUserIds.has(user.id)); + const someUsersSelected = props.data.users.some((user) => selectedUserIds.has(user.id)); + + useEffect(() => { + setSelectedUserIds((current) => pruneSelectedIds(current, props.data.users.map((user) => user.id))); + }, [props.data.users]); function openCreateDialog() { setEditingId(''); @@ -303,6 +313,16 @@ export function UsersPanel(props: IdentityPanelProps) { } } + async function batchUsers(action: IdentityBatchAction) { + setLocalError(''); + try { + await props.onBatchUsers(Array.from(selectedUserIds), action); + setSelectedUserIds(new Set()); + } catch (err) { + setLocalError(err instanceof Error ? err.message : '批量操作用户失败'); + } + } + async function submitWallet(event: FormEvent) { event.preventDefault(); setLocalError(''); @@ -346,9 +366,25 @@ export function UsersPanel(props: IdentityPanelProps) { actionLabel="新增用户" onCreate={openCreateDialog} /> + {props.data.users.length > 0 && ( + + )} {props.data.users.length ? ( + + setSelectedUserIds(checked === true ? new Set(props.data.users.map((user) => user.id)) : new Set())} + /> + 用户 角色 租户 @@ -360,6 +396,14 @@ export function UsersPanel(props: IdentityPanelProps) { {props.data.users.map((user) => ( + + setSelectedUserIds((current) => updateSelectedIds(current, user.id, checked === true))} + /> + {roleLabel(user.roles)} {tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)} @@ -443,6 +487,13 @@ export function UserGroupsPanel(props: IdentityPanelProps) { const [localError, setLocalError] = useState(''); const [pendingDeleteGroup, setPendingDeleteGroup] = useState(null); const [permissionGroup, setPermissionGroup] = useState(null); + const [selectedGroupIds, setSelectedGroupIds] = useState>(() => new Set()); + const allGroupsSelected = props.data.userGroups.length > 0 && props.data.userGroups.every((group) => selectedGroupIds.has(group.id)); + const someGroupsSelected = props.data.userGroups.some((group) => selectedGroupIds.has(group.id)); + + useEffect(() => { + setSelectedGroupIds((current) => pruneSelectedIds(current, props.data.userGroups.map((group) => group.id))); + }, [props.data.userGroups]); function openCreateDialog() { setEditingId(''); @@ -484,6 +535,16 @@ export function UserGroupsPanel(props: IdentityPanelProps) { } } + async function batchGroups(action: IdentityBatchAction) { + setLocalError(''); + try { + await props.onBatchUserGroups(Array.from(selectedGroupIds), action); + setSelectedGroupIds(new Set()); + } catch (err) { + setLocalError(err instanceof Error ? err.message : '批量操作用户组失败'); + } + } + return (
setLocalError('')} /> @@ -496,9 +557,25 @@ export function UserGroupsPanel(props: IdentityPanelProps) { actionLabel="新增用户组" onCreate={openCreateDialog} /> + {props.data.userGroups.length > 0 && ( + + )} {props.data.userGroups.length ? (
+ + setSelectedGroupIds(checked === true ? new Set(props.data.userGroups.map((group) => group.id)) : new Set())} + /> + 用户组 来源 优先级 @@ -512,6 +589,14 @@ export function UserGroupsPanel(props: IdentityPanelProps) { const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id); return ( + + setSelectedGroupIds((current) => updateSelectedIds(current, group.id, checked === true))} + /> + {group.source} {group.priority} @@ -575,7 +660,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) { <>
- 未配置规则时默认放行;拒绝规则优先于专属规则,修改后立即生效。 + 未配置白名单时继承上级可用范围;配置允许项后仅允许所选资源,拒绝规则始终优先。
Promise; onDeleteUser: (userId: string) => Promise; onDeleteUserGroup: (groupId: string) => Promise; + onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise; + onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise; onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise; onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; @@ -618,6 +705,18 @@ type IdentityPanelProps = { onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; }; +export function updateSelectedIds(current: Set, id: string, selected: boolean) { + const next = new Set(current); + if (selected) next.add(id); + else next.delete(id); + return next; +} + +export function pruneSelectedIds(current: Set, availableIds: string[]) { + const available = new Set(availableIds); + return new Set(Array.from(current).filter((id) => available.has(id))); +} + const defaultRechargeReason = '手动充值'; function identityHeaderMessage(props: Pick) { @@ -1018,8 +1117,8 @@ function PolicySummary(props: { parts: string[] }) { function permissionRuleSummary(summary: ReturnType) { const allow = summary.allow.platforms + summary.allow.models; const deny = summary.deny.platforms + summary.deny.models; - if (!allow && !deny) return '模型权限:未配置,默认放行'; - return `模型权限:专属 ${allow} 条,拒绝 ${deny} 条`; + if (!allow && !deny) return '模型权限:未配置白名单,继承上级'; + return `模型权限:允许 ${allow} 条,拒绝 ${deny} 条`; } function stringifyJson(value: unknown) { diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index 6cfadcf..3a91b61 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -435,6 +435,30 @@ align-items: center; } +.identityBatchToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--muted-foreground); + font-size: var(--font-size-sm); +} + +.identityBatchToolbar > div { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.identitySelectionCell { + flex: 0 0 2.5rem; + justify-content: center; +} + .accessGroupToolbar, .accessGroupHint { display: flex; @@ -494,6 +518,10 @@ color: var(--foreground); } +.accessRuleCleanupButton { + margin-left: auto; +} + .accessRuleDiagnosticRow { justify-content: space-between; padding-top: 0.625rem; @@ -774,15 +802,16 @@ } .userTable .shTableRow { - grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr); - min-width: 1120px; + grid-template-columns: 40px minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr); + min-width: 1160px; } .groupTable .shTableRow { - grid-template-columns: minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr); - min-width: 1305px; + grid-template-columns: 40px minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr); + min-width: 1345px; } +.userTable .shTableRow > :last-child, .groupTable .shTableRow > :last-child { position: sticky; right: 0; @@ -791,6 +820,7 @@ box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45); } +.userTable > .shTableRow:first-child > :last-child, .groupTable > .shTableRow:first-child > :last-child { z-index: 2; background: var(--surface-subtle); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 609facd..1b37c1e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -399,6 +399,20 @@ export interface GatewayUserUpsertRequest { status?: 'active' | 'disabled' | 'locked' | 'deleted' | string; } +export type IdentityBatchAction = 'enable' | 'disable' | 'delete'; + +export interface IdentityBatchRequest { + ids: string[]; + action: IdentityBatchAction; +} + +export interface IdentityBatchResponse { + action: IdentityBatchAction; + requestedCount: number; + affectedCount: number; + ids: string[]; +} + export interface GatewayTenant { id: string; tenantKey: string;