feat(identity): 支持用户和用户组批量管理
增加原子批量启用、禁用和删除接口及管理端多选操作,目标缺失时整批回滚。\n\n拆分管理端与 API Key 权限缓存并在弹窗保存后刷新候选;补齐失效规则一键清理样式、固定右侧操作列和 OpenAPI 契约。
This commit is contained in:
+190
-10
@@ -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": {
|
||||
|
||||
+132
-10
@@ -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:
|
||||
|
||||
@@ -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 必填。
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user