From abb87282a6ebd709d7db2a3051aac9ab65356457 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Fri, 23 Oct 2026 23:46:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(auth):=20SEC-24/SEC-07=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=A0=81v2=E5=A5=91=E7=BA=A6=E4=B8=8E=E8=B6=85?= =?UTF-8?q?=E7=AE=A1=E8=A7=92=E8=89=B2=E8=BF=81=E7=A7=BB=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/user-permissions-v2.md | 149 +++++++++++++++++++++++++++++++ docs/release/rbac-super-admin.md | 83 +++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 docs/api/user-permissions-v2.md create mode 100644 docs/release/rbac-super-admin.md diff --git a/docs/api/user-permissions-v2.md b/docs/api/user-permissions-v2.md new file mode 100644 index 00000000..77557d60 --- /dev/null +++ b/docs/api/user-permissions-v2.md @@ -0,0 +1,149 @@ +# 用户权限码 v2 契约(SEC-24 / SEC-07) + +面向前端(花卷)对齐。本契约描述新增的 `GET /api/user/permissions`,以及它与旧接口 `GET /api/user/codes` 的关系。后端鉴权以本文“匹配规则”为准,`permissions` 与后端 `CheckAuth` 使用同一份匹配代码生成,二者不会出现“前端显示可用、后端拒绝”的差异(超管码与下文列出的例外除外)。 + +## 1. 接口 + +| 项目 | 值 | +| --- | --- | +| 方法 / 路径 | `GET /api/user/permissions` | +| 认证 | 需要登录(Bearer JWT,与 `/api/user/codes` 相同) | +| 授权 | 任何已登录用户都可调用,只返回**调用者本人**的权限;不接受 `id` 等参数 | +| 旧接口 | `GET /api/user/codes` **保持不变**:仍返回角色授予的原始 `path` 字符串数组(不带方法、不展开通配、去重),前端可按自己的节奏迁移 | + +## 2. 响应 + +外层仍是统一的 `ApiResponse`(`code` / `data` / `message`),`data` 结构: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `version` | int | 固定为 `2`。后续若格式变化会递增 | +| `super_admin` | bool | 调用者是否持有启用中的内置超级管理员角色(`code=super_admin` 且 `is_system=1`) | +| `permissions` | string[] | 权限码列表,**已排序、已去重**,永不为 `null`(没有权限时是 `[]`) | + +### 2.1 权限码格式 + +``` +<空格><路由模板> +``` + +- `METHOD` 只会是 `GET` / `POST` / `PUT` / `DELETE`(大写)。 +- `路由模板` 是 gin 注册时的模板,参数保持 `:name` 原样,例如 `PUT /api/tree/cloud/account/:id/status`。 +- 前端匹配方式:把“当前要调用的 方法 + 路由模板”拼成同样格式,用 `Set.has()` 精确比较即可。**不要**把真实 ID 代入后再比较,也**不要**把 `:param` 当作通配。 +- AIOps 代理(`/api/v1/*path`)下的接口按 AIOps 接口目录展开成具体模板,例如 `GET /api/v1/assistant/session/:session_id`。若角色被授予了整个代理入口,还会额外出现 ` /api/v1/*path` 本身。 + +### 2.2 超管码 + +超级管理员不展开列表,`permissions` **只包含一个元素** `"*"`(超管码),并且 `super_admin=true`。 + +- `"*"` 只会在 `super_admin=true` 时出现,且不会与其他权限码混在一起。 +- 前端判断建议:`super_admin === true || permissions.includes('*')` 即视为全部可用。 +- 选择超管码而不是全量列表的原因:超管在后端是“无条件放行”,全量列表会随路由增减而漂移,也比实际授权更窄(目录外的接口超管同样能访问)。 + +### 2.3 通配授予的展开 + +角色里保存的授予(`cl_system_apis.path` + `method`)按下列规则展开到**已注册路由目录**(见第 4 节),只保留方法相同且被覆盖的路由模板: + +| 授予写法 | 含义 | 展开结果 | +| --- | --- | --- | +| 普通模板,如 `/api/k8s/clusters/:cluster_id/events/:namespace/:name` | 只覆盖**完全相同**的模板 | 1 条(若该模板已注册) | +| `/*` | 覆盖所有模板 | 该方法下目录内全部路由 | +| 以 `*` 结尾,如 `/api/k8s/*` | 对**模板字符串**做前缀匹配(前缀为去掉末尾 `*` 的部分) | 该方法下所有以 `/api/k8s/` 开头的模板 | +| gin 通配模板,如 `/api/v1/*path` | 与模板完全相同;对 AIOps 代理而言表示整个代理模块 | ` /api/v1/*path` 以及该方法下全部 AIOps 目录接口 | +| 其他含 `*` 的写法(开头 `*`、中间 `*`、多个 `*`) | **不再支持**,不覆盖任何模板 | 0 条 | +| 未注册的路径 / 已失效的授予 | 不覆盖任何模板 | 0 条 | + +### 2.4 不出现在列表中的接口 + +- 登录后即可访问、不经角色授权的接口:`/api/user/login`、`/api/user/logout`、`/api/user/refresh_token`、`/api/user/profile`、`/api/user/codes`、`/api/user/permissions`、`/api/not_auth/getBindIps`、`/api/not_auth/getTreeNodeBindIps`。 +- 仅超管可用的接口,即使角色里授予了也不会出现(后端同样会拒绝非超管):`POST /api/user/signup`、`/api/department` 下全部接口。 +- 非 `/api/` 前缀的路由(`/`、`/health`、`/ready` 等)。 + +## 3. 示例 + +### 3.1 普通用户 + +角色授予:`GET /api/k8s/clusters/:cluster_id/events/:namespace/:name`、`PUT /api/tree/cloud/account/:id/status`、`GET /api/v1/assistant/*`。 + +```json +{ + "code": 0, + "data": { + "version": 2, + "super_admin": false, + "permissions": [ + "GET /api/k8s/clusters/:cluster_id/events/:namespace/:name", + "GET /api/v1/assistant/config", + "GET /api/v1/assistant/health", + "GET /api/v1/assistant/info", + "GET /api/v1/assistant/ready", + "GET /api/v1/assistant/session/:session_id", + "GET /api/v1/assistant/stream", + "PUT /api/tree/cloud/account/:id/status" + ] + }, + "message": "请求成功" +} +``` + +注意:上例**不会**包含 `GET /api/k8s/clusters/:cluster_id/events/nodes/:node_name` 或 `PUT /api/tree/cloud/account/batch/status`,后端也会拒绝这两个请求(这是 SEC-24 修复的授权外溢)。 + +### 3.2 超级管理员(含 admin) + +```json +{ + "code": 0, + "data": { + "version": 2, + "super_admin": true, + "permissions": ["*"] + }, + "message": "请求成功" +} +``` + +### 3.3 没有任何角色(或角色全部禁用) + +```json +{ + "code": 0, + "data": { + "version": 2, + "super_admin": false, + "permissions": [] + }, + "message": "请求成功" +} +``` + +同一用户调用旧接口 `GET /api/user/codes` 的结果不变,例如 3.1 的用户仍得到: + +```json +{ + "code": 0, + "data": [ + "/api/k8s/clusters/:cluster_id/events/:namespace/:name", + "/api/tree/cloud/account/:id/status", + "/api/v1/assistant/*" + ], + "message": "请求成功" +} +``` + +(顺序由数据库决定;admin 的内置超管角色本身不绑定接口,所以 admin 的旧 `codes` 仍是 `[]`,与改动前一致。) + +## 4. 后端匹配规则(与 `permissions` 同源) + +1. 鉴权对象是 **HTTP 方法 + gin 路由模板**(`c.FullPath()`),不是真实请求路径。`:param` 不再作为通配符参与授权匹配。 +2. 角色授予的 `/*` 和末尾 `*` 只对**模板**做前缀匹配。 +3. `c.FullPath()` 为空(请求未命中任何路由,即 404)时:非超管直接返回 `403 无权限访问该接口`,不暴露路由是否存在;超管放行,由 gin 返回 404。 +4. AIOps 代理入口 `/api/v1/*path` 的 `FullPath` 只有通配模板,后端用内置 AIOps 接口目录把真实路径解析为最具体的目录模板(逐段比较,静态段优先于 `:param`,`:param` 只匹配一个非空且不为 `.`/`..` 的段),再按第 1、2 条比较;解析不到目录模板时,只有覆盖 `/api/v1/*path` 本身的授予(如 `/api/v1/*path`、`/api/*`、`/*`)能放行。 +5. 只计入启用中(`status=1`)、未删除的角色及其未删除的接口。 +6. 超管(持有启用中的内置超管角色)对所有请求放行;`POST /api/user/signup` 与 `/api/department/*` 只允许超管。 +7. 目录来源:运行时 gin 已注册路由(`engine.Routes()`)+ 内置 AIOps 接口目录。之所以不用数据库 `cl_system_apis` 做展开:数据库里可能有已下线或手工录入的条目,展开后会给前端“幽灵权限”;而已注册路由正是 `FullPath` 的取值空间,和后端判定严格一致。 + +## 5. 前端迁移建议 + +- 读取 `permissions`,构造 `Set`;按钮/菜单声明自己依赖的 `METHOD 模板`。 +- `super_admin` 为真时跳过所有判断。 +- 旧 `codes` 中基于 `*` / `/*` 的前端通配逻辑可以在迁移完成后删除。 diff --git a/docs/release/rbac-super-admin.md b/docs/release/rbac-super-admin.md new file mode 100644 index 00000000..783b2c9b --- /dev/null +++ b/docs/release/rbac-super-admin.md @@ -0,0 +1,83 @@ +# 内置超级管理员角色与精确鉴权(SEC-07 / SEC-24)迁移说明 + +## 变更摘要 + +- SEC-07:去掉所有按用户名 `admin` 判断特权的逻辑,改为判断用户是否持有**启用中的内置超级管理员角色**(`cl_system_roles.code='super_admin' AND is_system=1 AND status=1`)。 +- SEC-24:`CheckAuth` 改为“HTTP 方法 + gin 路由模板”精确比较;新增 `GET /api/user/permissions`,契约见 [docs/api/user-permissions-v2.md](../api/user-permissions-v2.md)。 + +## 表结构 + +**没有新表、新列、新索引,没有修改任何 GORM 模型 tag。** `pkg/di/init.go` 的 AutoMigrate 不会因本次改动产生新的 DDL。 + +## 种子数据(启动引导,幂等) + +每次启动在权限目录与 Mock 初始化之后执行 `bootstrapSuperAdmin`,在一个事务里: + +1. 查找 `code='super_admin' AND is_system=1` 且未删除的角色,按 `id` 取最小的一条;没有则插入: + + | 列 | 值 | + | --- | --- | + | `name` | `超级管理员` | + | `code` | `super_admin` | + | `description` | `内置超级管理员角色,由启动引导维护,不可修改或删除` | + | `status` | `1` | + | `is_system` | `1` | + + 该角色**不绑定任何接口**,超管在鉴权中直接放行。 +2. 查找所有未删除且用户名**严格等于** `admin` 的用户(Go 侧大小写敏感比较,避免 MySQL 排序规则把 `Admin` 也匹配进来),若尚未绑定该角色则在 `cl_system_user_roles` 插入一行。已绑定时不写库。 +3. 若已存在的内置超管角色 `status != 1`(只能是有人直接改库),引导**不会自动重新启用**,只打印告警;此时 admin 不再是超管,需人工处理(见“应急”)。 +4. 如果存在 `code='super_admin'` 但 `is_system=0` 的普通角色(历史上手工创建的同名角色),引导**不会提升**它,只告警并另建系统角色;持有那个普通角色的用户不会获得超管权限。 + +幂等性:重复启动不会产生新行。多副本**同时首次**启动时,因角色表没有唯一索引,极小概率会各插入一条系统超管角色;这不影响正确性(判断依据是 `code + is_system`,任一条都生效),下次启动会统一绑定 `id` 最小的那条,多余的一条可在确认无用户绑定后手工删除。 + +没有设置 `BOOTSTRAP_ADMIN_PASSWORD` 且未开启 Mock 的空库不会创建 admin,引导只建角色不绑定用户。 + +## 权限相关的其他行为变化 + +- 角色接口 `POST /api/role/assign_users`、`POST /api/role/revoke_users`:涉及内置超管角色时,调用者必须是超管,否则 `403`。防止“有分配角色权限的普通用户把自己提升为超管”。 +- `PUT /api/role/update/:id`、`DELETE /api/role/delete/:id` 原本就拒绝系统角色;`POST /api/role/create` 原本就强制 `is_system=0`,因此无法通过 API 伪造系统超管角色。 +- 由于引导每次启动都会把 `admin` 绑回超管角色,**撤销 admin 的超管角色只在下次重启前有效**。 +- 用户名 `admin` 仍是保留名,注册接口拒绝(大小写不敏感),以免被他人注册后在重启时自动获得超管。 + +## 上线步骤 + +1. 备份 MySQL(至少 `cl_system_roles`、`cl_system_user_roles`、`cl_system_role_apis`、`cl_system_apis`)。 +2. 上线前用只读 SQL 盘点会受精确鉴权影响的授予(在数据库副本或只读账号执行): + + ```sql + -- 含 * 但不是 /* 或末尾 * 的授予:上线后不再生效 + SELECT id, path, method FROM cl_system_apis + WHERE deleted_at = 0 AND path LIKE '%*%' + AND path <> '/*' AND path NOT LIKE '%*' ; + -- 已存在的同名角色(引导不会提升它们) + SELECT id, name, code, is_system, status FROM cl_system_roles + WHERE deleted_at = 0 AND (code = 'super_admin' OR name = '超级管理员'); + ``` + 第一条结果里形如 `/api/v1/*path` 的 gin 通配模板仍然有效(与模板完全相等),其余需要改成末尾 `*` 或具体模板。 +3. 部署新版本,启动日志确认无 `初始化超级管理员角色失败`。 +4. 验证:以 admin 登录,`GET /api/user/permissions` 返回 `super_admin=true`、`permissions=["*"]`;以普通测试账号验证契约第 3.1 节的拒绝用例。 + +## 回滚 + +应用回滚到旧镜像即可,**不需要删除数据**: + +- 旧版本按用户名判断 admin,不读取新角色;新增的系统角色和绑定行对旧版本无副作用(旧版本鉴权会遍历该角色的接口列表,而它是空的)。 +- 旧版本的“`:param` 匹配任意段”宽松鉴权会随回滚恢复,即 SEC-24 修复的外溢重新出现,回滚需安全负责人知情。 +- 如需彻底清理种子数据(可选,先确认没有其他用户绑定该角色): + + ```sql + -- 先查 + SELECT r.id, ur.user_id FROM cl_system_roles r + LEFT JOIN cl_system_user_roles ur ON ur.role_id = r.id + WHERE r.code = 'super_admin' AND r.is_system = 1 AND r.deleted_at = 0; + -- 确认后删除(:role_id 为上面查到的 id) + DELETE FROM cl_system_user_roles WHERE role_id = :role_id; + DELETE FROM cl_system_role_apis WHERE role_id = :role_id; + DELETE FROM cl_system_roles WHERE id = :role_id AND is_system = 1; + ``` + 注意:只要新版本再次启动,引导会重新创建并绑定。 + +## 应急 + +- admin 被误撤销超管:重启服务即可由引导重新绑定;不能重启时,用有权限的 DBA 账号插入 `cl_system_user_roles(user_id, role_id)`。 +- 内置超管角色被改库禁用:`UPDATE cl_system_roles SET status = 1 WHERE code='super_admin' AND is_system=1;`,然后让 admin 重新登录(权限每个请求实时读库,无需重启)。 From 775dc8c39a33c588930489cf65e519be272d1534 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Fri, 23 Oct 2026 23:51:25 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(auth):=20SEC-07=20=E4=BB=A5=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E8=B6=85=E7=AE=A1=E8=A7=92=E8=89=B2=E5=8F=96=E4=BB=A3?= =?UTF-8?q?=20admin=20=E7=94=A8=E6=88=B7=E5=90=8D=E7=89=B9=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 鉴权中间件、部门管理、资料修改不再比较用户名 admin,统一以启用中的 内置超管角色(code=super_admin 且 is_system=1)判定,并写入请求上下文 - 新增幂等启动引导:确保内置超管角色存在并把 admin 绑定到该角色 - 授予/撤销内置超管角色仅限超管操作,防止持有角色分配权限的用户自我提权 - 无模型/表结构变更,种子数据与回滚见 docs/release/rbac-super-admin.md --- internal/app/app.go | 5 + internal/app/bootstrap_superadmin.go | 80 ++++++++ internal/app/bootstrap_superadmin_test.go | 180 ++++++++++++++++++ internal/middleware/auth.go | 28 ++- internal/middleware/auth_superadmin_test.go | 87 +++++++++ internal/middleware/signup_test.go | 20 +- internal/permission/superadmin.go | 62 ++++++ internal/permission/superadmin_test.go | 50 +++++ internal/system/api/department_handler.go | 6 +- .../system/api/department_handler_test.go | 32 ++-- internal/system/api/role_handler.go | 62 +++++- internal/system/api/superadmin_guard_test.go | 152 +++++++++++++++ internal/system/api/user_handler.go | 6 +- 13 files changed, 736 insertions(+), 34 deletions(-) create mode 100644 internal/app/bootstrap_superadmin.go create mode 100644 internal/app/bootstrap_superadmin_test.go create mode 100644 internal/middleware/auth_superadmin_test.go create mode 100644 internal/permission/superadmin.go create mode 100644 internal/permission/superadmin_test.go create mode 100644 internal/system/api/superadmin_guard_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 7d7041c3..857a5859 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -156,6 +156,11 @@ func (a *App) Run(ctx context.Context) error { } } + // 必须在管理员账号可能被创建(bootstrapAdmin / Mock)之后执行 + if err := a.bootstrapSuperAdmin(ctx); err != nil { + return fmt.Errorf("初始化超级管理员角色失败: %w", err) + } + if err := a.initK8sClients(ctx); err != nil { a.logger.Warn("K8s客户端初始化失败", zap.Error(err)) } diff --git a/internal/app/bootstrap_superadmin.go b/internal/app/bootstrap_superadmin.go new file mode 100644 index 00000000..d7375758 --- /dev/null +++ b/internal/app/bootstrap_superadmin.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + databaseLogger "github.com/GoSimplicity/AI-CloudOps/pkg/logger" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// bootstrapSuperAdmin 幂等地确保内置超管角色存在,并把内置 admin 账号绑定到该角色。 +// 运行期鉴权只认角色,不认用户名,因此这一步是 admin 不被锁在门外的前提。 +func (a *App) bootstrapSuperAdmin(ctx context.Context) error { + return ensureSuperAdmin(ctx, a.db, a.logger) +} + +func ensureSuperAdmin(ctx context.Context, db *gorm.DB, logger *zap.Logger) error { + if logger == nil { + logger = zap.NewNop() + } + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var roles []model.Role + if err := tx.Where("code = ?", permission.SuperAdminRoleCode).Order("id").Find(&roles).Error; err != nil { + return databaseLogger.WrapDatabaseError("初始化超级管理员角色: 查询角色失败", err) + } + var role *model.Role + for i := range roles { + if permission.IsSuperAdminIdentity(&roles[i]) { + if role == nil { + role = &roles[i] + } + continue + } + // 普通角色借用了超管编码:不提升它,只提示人工清理 + logger.Warn("存在使用超管编码的非系统角色,该角色不具备超管权限", + zap.Int("role_id", roles[i].ID), zap.String("role_name", roles[i].Name)) + } + if role == nil { + role = &model.Role{ + Name: permission.SuperAdminRoleName, + Code: permission.SuperAdminRoleCode, + Description: permission.SuperAdminRoleDescription, + Status: 1, + IsSystem: 1, + } + if err := tx.Create(role).Error; err != nil { + return databaseLogger.WrapDatabaseError("初始化超级管理员角色: 创建角色失败", err) + } + logger.Info("已创建内置超级管理员角色", zap.Int("role_id", role.ID)) + } else if role.Status != 1 { + logger.Warn("内置超级管理员角色已被禁用,启动引导不会自动启用;持有该角色的账号当前没有超管权限", + zap.Int("role_id", role.ID)) + } + + var users []model.User + if err := tx.Select("id", "username").Where("username = ?", permission.BootstrapAdminUsername).Find(&users).Error; err != nil { + return databaseLogger.WrapDatabaseError("初始化超级管理员角色: 查询管理员失败", err) + } + for _, user := range users { + // MySQL 默认排序规则大小写不敏感,这里再按字节精确比较,避免绑定 "Admin" 等账号 + if user.Username != permission.BootstrapAdminUsername { + continue + } + var bound int64 + if err := tx.Model(&model.UserRole{}).Where("user_id = ? AND role_id = ?", user.ID, role.ID).Count(&bound).Error; err != nil { + return databaseLogger.WrapDatabaseError("初始化超级管理员角色: 查询绑定失败", err) + } + if bound > 0 { + continue + } + if err := tx.Create(&model.UserRole{UserID: user.ID, RoleID: role.ID}).Error; err != nil { + return databaseLogger.WrapDatabaseError("初始化超级管理员角色: 绑定管理员失败", err) + } + logger.Info("已将内置管理员绑定到超级管理员角色", zap.Int("user_id", user.ID), zap.Int("role_id", role.ID)) + } + return nil + }) +} diff --git a/internal/app/bootstrap_superadmin_test.go b/internal/app/bootstrap_superadmin_test.go new file mode 100644 index 00000000..6602eb33 --- /dev/null +++ b/internal/app/bootstrap_superadmin_test.go @@ -0,0 +1,180 @@ +package app + +import ( + "context" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + "github.com/GoSimplicity/AI-CloudOps/internal/system/dao" + "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openRBACTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "rbac.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&model.User{}, &model.Api{}, &model.Role{}, &model.RoleApi{}, &model.UserRole{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func superRoles(t *testing.T, db *gorm.DB) []model.Role { + t.Helper() + var roles []model.Role + if err := db.Where("code = ? AND is_system = 1", permission.SuperAdminRoleCode).Find(&roles).Error; err != nil { + t.Fatal(err) + } + return roles +} + +func isSuper(t *testing.T, db *gorm.DB, userID int) bool { + t.Helper() + roles, err := dao.NewRoleDAO(db).GetRoles(context.Background(), userID) + if err != nil { + t.Fatal(err) + } + return permission.HasSuperAdminRole(roles) +} + +func TestEnsureSuperAdminIsIdempotentAndBindsOnlyAdmin(t *testing.T) { + db := openRBACTestDB(t) + admin := model.User{Username: "admin", Enable: 1} + lookalike := model.User{Username: "Admin", Enable: 1} + member := model.User{Username: "member", Enable: 1} + for _, u := range []*model.User{&admin, &lookalike, &member} { + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + } + // 历史上手工建的同编码普通角色,不能被提升 + custom := model.Role{Name: "自建超管", Code: permission.SuperAdminRoleCode, Status: 1} + if err := db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.UserRole{UserID: member.ID, RoleID: custom.ID}).Error; err != nil { + t.Fatal(err) + } + + for i := 0; i < 3; i++ { + if err := ensureSuperAdmin(context.Background(), db, zap.NewNop()); err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + + roles := superRoles(t, db) + if len(roles) != 1 || roles[0].Status != 1 || roles[0].Name != permission.SuperAdminRoleName { + t.Fatalf("unexpected system roles: %+v", roles) + } + var custom2 model.Role + if err := db.First(&custom2, custom.ID).Error; err != nil || custom2.IsSystem != 0 { + t.Fatalf("custom role promoted: %+v err=%v", custom2, err) + } + var bindings []model.UserRole + if err := db.Where("role_id = ?", roles[0].ID).Find(&bindings).Error; err != nil { + t.Fatal(err) + } + if len(bindings) != 1 || bindings[0].UserID != admin.ID { + t.Fatalf("bindings=%+v want only admin(%d)", bindings, admin.ID) + } + if !isSuper(t, db, admin.ID) || isSuper(t, db, lookalike.ID) || isSuper(t, db, member.ID) { + t.Fatal("super admin identity resolved incorrectly") + } +} + +func TestEnsureSuperAdminRebindsAndDoesNotReenable(t *testing.T) { + db := openRBACTestDB(t) + if err := ensureSuperAdmin(context.Background(), db, nil); err != nil { + t.Fatal(err) + } + if n := len(superRoles(t, db)); n != 1 { + t.Fatalf("empty install created %d roles", n) + } + // admin 在角色之后才创建(例如 Mock),下次启动应补绑 + admin := model.User{Username: "admin", Enable: 1} + if err := db.Create(&admin).Error; err != nil { + t.Fatal(err) + } + if err := ensureSuperAdmin(context.Background(), db, nil); err != nil { + t.Fatal(err) + } + if !isSuper(t, db, admin.ID) { + t.Fatal("admin not bound on second boot") + } + // 人为撤销后重启会重新绑定 + if err := db.Where("user_id = ?", admin.ID).Delete(&model.UserRole{}).Error; err != nil { + t.Fatal(err) + } + if err := ensureSuperAdmin(context.Background(), db, nil); err != nil { + t.Fatal(err) + } + if !isSuper(t, db, admin.ID) { + t.Fatal("admin not re-bound after revoke") + } + // 被直接改库禁用的超管角色不会被自动启用 + if err := db.Model(&model.Role{}).Where("code = ?", permission.SuperAdminRoleCode).Update("status", 0).Error; err != nil { + t.Fatal(err) + } + if err := ensureSuperAdmin(context.Background(), db, nil); err != nil { + t.Fatal(err) + } + if roles := superRoles(t, db); len(roles) != 1 || roles[0].Status != 0 { + t.Fatalf("disabled role changed: %+v", roles) + } + if isSuper(t, db, admin.ID) { + t.Fatal("disabled super role still grants super admin") + } +} + +// 端到端:真实 RoleDAO/RoleService + 真实鉴权中间件,admin 经引导后仍能使用 +// 仅超管可用的接口,而同名但未绑定角色的账号不能。 +func TestBootstrappedAdminKeepsAccessThroughRealMiddleware(t *testing.T) { + db := openRBACTestDB(t) + admin := model.User{Username: "admin", Enable: 1} + member := model.User{Username: "member", Enable: 1} + for _, u := range []*model.User{&admin, &member} { + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + } + if err := ensureSuperAdmin(context.Background(), db, nil); err != nil { + t.Fatal(err) + } + roles := service.NewRoleService(dao.NewRoleDAO(db), zap.NewNop()) + for _, tt := range []struct { + claims jwt.UserClaims + want int + }{ + {jwt.UserClaims{Uid: admin.ID, Username: "admin"}, 204}, + {jwt.UserClaims{Uid: member.ID, Username: "member"}, 403}, + // 伪造用户名没有用:特权跟着 uid 的角色走 + {jwt.UserClaims{Uid: member.ID, Username: "admin"}, 403}, + } { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", tt.claims); c.Next() }) + engine.Use(middleware.NewAuthMiddleware(roles).CheckAuth()) + engine.POST("/api/user/signup", func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("POST", "/api/user/signup", nil)) + if rec.Code != tt.want { + t.Fatalf("%+v: status=%d want=%d", tt.claims, rec.Code, tt.want) + } + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1b8ab33a..f13eaeb0 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -28,6 +28,8 @@ package middleware import ( "strings" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" @@ -152,14 +154,27 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { return } - if path == "/api/user/signup" && user.Username != "admin" { + // 特权只来自内置超管角色(code=super_admin 且 is_system=1 且启用),不再按用户名判断 + roles, err := am.roleService.GetUserRoles(c, user.Uid) + if err != nil { + base.ErrorWithMessage(c, "获取用户角色失败") + c.Abort() + return + } + if roles == nil { + roles = &model.ListResp[*model.Role]{} + } + super := permission.HasSuperAdminRole(roles.Items) + permission.MarkSuperAdmin(c, super) + + if path == "/api/user/signup" && !super { base.ForbiddenError(c, "仅管理员可创建用户") c.Abort() return } - // 管理员放行 - if user.Username == "admin" { + // 超级管理员放行 + if super { c.Next() return } @@ -173,13 +188,6 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { return } - roles, err := am.roleService.GetUserRoles(c, user.Uid) - if err != nil { - base.ErrorWithMessage(c, "获取用户角色失败") - c.Abort() - return - } - for _, role := range roles.Items { // 跳过禁用角色 if role.Status != 1 { diff --git a/internal/middleware/auth_superadmin_test.go b/internal/middleware/auth_superadmin_test.go new file mode 100644 index 00000000..2d6dc562 --- /dev/null +++ b/internal/middleware/auth_superadmin_test.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +// fixedRoles 返回固定角色集合的 RoleService 假实现;err 非空时模拟数据库故障。 +type fixedRoles struct { + service.RoleService + roles []*model.Role + err error +} + +func (r fixedRoles) GetUserRoles(context.Context, int) (*model.ListResp[*model.Role], error) { + if r.err != nil { + return nil, r.err + } + return &model.ListResp[*model.Role]{Items: r.roles}, nil +} + +func TestPrivilegeComesFromSuperAdminRoleNotUsername(t *testing.T) { + superRole := &model.Role{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 1} + for _, tt := range []struct { + name string + claims jwt.UserClaims + roles []*model.Role + status int + super bool + }{ + {"admin username alone is ordinary", jwt.UserClaims{Uid: 1, Username: "admin"}, nil, 403, false}, + {"admin with super role", jwt.UserClaims{Uid: 1, Username: "admin"}, []*model.Role{superRole}, 204, true}, + {"renamed account with super role", jwt.UserClaims{Uid: 9, Username: "ops-lead"}, []*model.Role{superRole}, 204, true}, + {"custom role reusing super code", jwt.UserClaims{Uid: 9, Username: "ops"}, []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 0, Status: 1}}, 403, false}, + {"disabled super role", jwt.UserClaims{Uid: 1, Username: "admin"}, []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 0}}, 403, false}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", tt.claims); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: tt.roles}).CheckAuth()) + var sawSuper bool + engine.DELETE("/api/user/:id/delete", func(c *gin.Context) { sawSuper = permission.IsSuperAdmin(c); c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("DELETE", "/api/user/3/delete", nil)) + if rec.Code != tt.status { + t.Fatalf("status=%d want=%d", rec.Code, tt.status) + } + if tt.status == 204 && sawSuper != tt.super { + t.Fatalf("super mark=%v want=%v", sawSuper, tt.super) + } + }) + } +} + +func TestOrdinaryGrantMarksRequestAsNotSuperAdmin(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "reader"}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: []*model.Role{{Status: 1, Apis: []*model.Api{{Path: "/api/item/:id", Method: 1}}}}}).CheckAuth()) + sawSuper := true + engine.GET("/api/item/:id", func(c *gin.Context) { sawSuper = permission.IsSuperAdmin(c); c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/item/1", nil)) + if rec.Code != 204 || sawSuper { + t.Fatalf("status=%d super=%v", rec.Code, sawSuper) + } +} + +func TestRoleLookupFailureFailsClosed(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1, Username: "admin"}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{err: errors.New("db down")}).CheckAuth()) + called := false + engine.GET("/api/item/:id", func(c *gin.Context) { called = true; c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/item/1", nil)) + if called { + t.Fatalf("handler reached despite role lookup failure (status=%d)", rec.Code) + } +} diff --git a/internal/middleware/signup_test.go b/internal/middleware/signup_test.go index 0df16ce4..51068418 100644 --- a/internal/middleware/signup_test.go +++ b/internal/middleware/signup_test.go @@ -4,20 +4,30 @@ import ( "net/http/httptest" "testing" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" ijwt "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" "github.com/gin-gonic/gin" ) func TestSignupRequiresAuthenticatedAdministrator(t *testing.T) { + superRole := &model.Role{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 1} + signupGrant := &model.Role{Status: 1, Apis: []*model.Api{{Path: "/api/user/signup", Method: 2}, {Path: "/*", Method: 2}}} for _, tt := range []struct { name string claims *ijwt.UserClaims + roles []*model.Role status int }{ - {"anonymous", nil, 401}, - {"ordinary account", &ijwt.UserClaims{Username: "member", AccountType: 1}, 403}, - {"service account", &ijwt.UserClaims{Username: "service", AccountType: 2}, 403}, - {"administrator", &ijwt.UserClaims{Username: "admin"}, 204}, + {"anonymous", nil, nil, 401}, + {"ordinary account", &ijwt.UserClaims{Uid: 2, Username: "member", AccountType: 1}, nil, 403}, + {"service account", &ijwt.UserClaims{Uid: 3, Username: "service", AccountType: 2}, nil, 403}, + // 角色里显式授予 signup 也不够:建号只属于超管 + {"granted but not super admin", &ijwt.UserClaims{Uid: 4, Username: "operator"}, []*model.Role{signupGrant}, 403}, + // 用户名叫 admin 但没有超管角色:不再有任何特权 + {"username admin without role", &ijwt.UserClaims{Uid: 1, Username: "admin"}, nil, 403}, + {"super admin role", &ijwt.UserClaims{Uid: 5, Username: "alice"}, []*model.Role{superRole}, 204}, + {"administrator", &ijwt.UserClaims{Uid: 1, Username: "admin"}, []*model.Role{superRole}, 204}, } { t.Run(tt.name, func(t *testing.T) { engine := gin.New() @@ -26,7 +36,7 @@ func TestSignupRequiresAuthenticatedAdministrator(t *testing.T) { } else { engine.Use(func(c *gin.Context) { c.Set("user", *tt.claims); c.Next() }) } - engine.Use(NewAuthMiddleware(nil).CheckAuth()) + engine.Use(NewAuthMiddleware(fixedRoles{roles: tt.roles}).CheckAuth()) engine.POST("/api/user/signup", func(c *gin.Context) { c.Status(204) }) rec := httptest.NewRecorder() engine.ServeHTTP(rec, httptest.NewRequest("POST", "/api/user/signup", nil)) diff --git a/internal/permission/superadmin.go b/internal/permission/superadmin.go new file mode 100644 index 00000000..6ae1b50d --- /dev/null +++ b/internal/permission/superadmin.go @@ -0,0 +1,62 @@ +// Package permission 集中定义 RBAC 的身份判定与授权匹配规则,供鉴权中间件、 +// 权限码接口与启动引导共用,避免各处各写一套导致语义漂移。 +package permission + +import ( + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/gin-gonic/gin" +) + +const ( + // SuperAdminRoleCode 内置超级管理员角色编码。身份判定同时要求 is_system=1, + // 普通角色即使使用同样的编码也不会获得超管身份(CreateRole 强制 is_system=0)。 + SuperAdminRoleCode = "super_admin" + // SuperAdminRoleName 内置超级管理员角色名称。 + SuperAdminRoleName = "超级管理员" + // SuperAdminRoleDescription 内置超级管理员角色描述。 + SuperAdminRoleDescription = "内置超级管理员角色,由启动引导维护,不可修改或删除" + // BootstrapAdminUsername 启动引导会绑定到超管角色的内置账号。它只用于引导, + // 运行期授权从不按用户名判断。 + BootstrapAdminUsername = "admin" + + superAdminContextKey = "cloudops.rbac.super_admin" +) + +// IsSuperAdminIdentity 判断角色是否是内置超管角色本身(不看启用状态)。 +func IsSuperAdminIdentity(role *model.Role) bool { + return role != nil && role.IsSystem == 1 && role.Code == SuperAdminRoleCode +} + +// IsSuperAdminRole 判断角色是否是启用中的内置超管角色。 +func IsSuperAdminRole(role *model.Role) bool { + return IsSuperAdminIdentity(role) && role.Status == 1 +} + +// HasSuperAdminRole 判断角色集合中是否包含启用中的内置超管角色。 +func HasSuperAdminRole(roles []*model.Role) bool { + for _, role := range roles { + if IsSuperAdminRole(role) { + return true + } + } + return false +} + +// MarkSuperAdmin 由鉴权中间件在读取用户角色后写入请求上下文。 +func MarkSuperAdmin(c *gin.Context, super bool) { + c.Set(superAdminContextKey, super) +} + +// IsSuperAdmin 读取鉴权中间件写入的超管标记。未经过鉴权中间件(没有标记)时 +// 一律视为非超管,保证 fail closed。 +func IsSuperAdmin(c *gin.Context) bool { + if c == nil { + return false + } + value, ok := c.Get(superAdminContextKey) + if !ok { + return false + } + super, ok := value.(bool) + return ok && super +} diff --git a/internal/permission/superadmin_test.go b/internal/permission/superadmin_test.go new file mode 100644 index 00000000..4767bac7 --- /dev/null +++ b/internal/permission/superadmin_test.go @@ -0,0 +1,50 @@ +package permission + +import ( + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/gin-gonic/gin" +) + +func TestSuperAdminIdentityRequiresSystemRoleAndStatus(t *testing.T) { + for _, tt := range []struct { + name string + role *model.Role + super bool + }{ + {"nil", nil, false}, + {"system enabled", &model.Role{Code: SuperAdminRoleCode, IsSystem: 1, Status: 1}, true}, + {"system disabled", &model.Role{Code: SuperAdminRoleCode, IsSystem: 1, Status: 0}, false}, + {"custom role reusing code", &model.Role{Code: SuperAdminRoleCode, IsSystem: 0, Status: 1}, false}, + {"other system role", &model.Role{Code: "auditor", IsSystem: 1, Status: 1}, false}, + {"name only", &model.Role{Name: SuperAdminRoleName, Code: "x", IsSystem: 1, Status: 1}, false}, + } { + if got := IsSuperAdminRole(tt.role); got != tt.super { + t.Fatalf("%s: got %v want %v", tt.name, got, tt.super) + } + if got := HasSuperAdminRole([]*model.Role{{Code: "member", Status: 1}, tt.role}); got != tt.super { + t.Fatalf("%s (set): got %v want %v", tt.name, got, tt.super) + } + } +} + +func TestSuperAdminContextFailsClosed(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + if IsSuperAdmin(c) || IsSuperAdmin(nil) { + t.Fatal("missing mark must not be super admin") + } + c.Set(superAdminContextKey, "true") + if IsSuperAdmin(c) { + t.Fatal("non-bool mark must not be super admin") + } + MarkSuperAdmin(c, true) + if !IsSuperAdmin(c) { + t.Fatal("mark not read back") + } + MarkSuperAdmin(c, false) + if IsSuperAdmin(c) { + t.Fatal("cleared mark still super admin") + } +} diff --git a/internal/system/api/department_handler.go b/internal/system/api/department_handler.go index 677207db..61aa773e 100644 --- a/internal/system/api/department_handler.go +++ b/internal/system/api/department_handler.go @@ -2,8 +2,8 @@ package api import ( "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" - userutils "github.com/GoSimplicity/AI-CloudOps/internal/system/utils" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "github.com/gin-gonic/gin" ) @@ -17,8 +17,8 @@ func (h *DepartmentHandler) RegisterRouters(server *gin.Engine) { group := server.Group("/api/department") // Organization assignments are administrative state, never self-service profile data. group.Use(func(c *gin.Context) { - claims, err := userutils.ExtractClaims(c) - if err != nil || claims.Username != "admin" { + // 超管身份由鉴权中间件根据内置超管角色写入上下文,未写入时视为非超管 + if !permission.IsSuperAdmin(c) { base.ForbiddenError(c, "仅管理员可管理部门及用户归属") c.Abort() return diff --git a/internal/system/api/department_handler_test.go b/internal/system/api/department_handler_test.go index e5f70d9c..09d987c0 100644 --- a/internal/system/api/department_handler_test.go +++ b/internal/system/api/department_handler_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/dao" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" @@ -33,7 +34,11 @@ func TestDepartmentHTTPPersistenceAndPermissions(t *testing.T) { } router := gin.New() router.Use(func(c *gin.Context) { - c.Set("user", jwt.UserClaims{Uid: user.ID, Username: c.GetHeader("X-Test-Identity")}) + // 模拟鉴权中间件:只有 "super" 身份被标记为持有内置超管角色; + // "admin" 身份只有用户名叫 admin,没有超管角色。 + identity := c.GetHeader("X-Test-Identity") + c.Set("user", jwt.UserClaims{Uid: user.ID, Username: identity}) + permission.MarkSuperAdmin(c, identity == "super") c.Next() }) NewDepartmentHandler(service.NewDepartmentService(dao.NewDepartmentDAO(db))).RegisterRouters(router) @@ -59,28 +64,31 @@ func TestDepartmentHTTPPersistenceAndPermissions(t *testing.T) { if status, _ := call("POST", "/api/department/create", "member", map[string]any{"name": "Forbidden"}); status != 403 { t.Fatalf("non-admin status %d", status) } - _, created := call("POST", "/api/department/create", "admin", map[string]any{"name": "Engineering"}) + if status, _ := call("POST", "/api/department/create", "admin", map[string]any{"name": "Forbidden"}); status != 403 { + t.Fatalf("username admin without super admin role status %d", status) + } + _, created := call("POST", "/api/department/create", "super", map[string]any{"name": "Engineering"}) if created["code"] != float64(0) { t.Fatal(created) } id := int(created["data"].(map[string]any)["id"].(float64)) - _, child := call("POST", "/api/department/create", "admin", map[string]any{"name": "Operations", "parent_id": id}) + _, child := call("POST", "/api/department/create", "super", map[string]any{"name": "Operations", "parent_id": id}) if child["code"] != float64(0) { t.Fatal(child) } childID := int(child["data"].(map[string]any)["id"].(float64)) - _, cycle := call("PUT", fmt.Sprintf("/api/department/%d/update", id), "admin", map[string]any{"name": "Engineering", "parent_id": childID}) + _, cycle := call("PUT", fmt.Sprintf("/api/department/%d/update", id), "super", map[string]any{"name": "Engineering", "parent_id": childID}) if cycle["code"] == float64(0) { t.Fatal("cycle accepted") } - _, nonempty := call("DELETE", fmt.Sprintf("/api/department/%d/delete", id), "admin", nil) + _, nonempty := call("DELETE", fmt.Sprintf("/api/department/%d/delete", id), "super", nil) if nonempty["code"] == float64(0) { t.Fatal("deleted parent with child") } if status, _ := call("PUT", fmt.Sprintf("/api/department/user/%d", user.ID), "member", map[string]int{"department_id": childID}); status != 403 { t.Fatal("non-admin assigned department") } - _, assigned := call("PUT", fmt.Sprintf("/api/department/user/%d", user.ID), "admin", map[string]int{"department_id": childID}) + _, assigned := call("PUT", fmt.Sprintf("/api/department/user/%d", user.ID), "super", map[string]int{"department_id": childID}) if assigned["code"] != float64(0) { t.Fatal(assigned) } @@ -89,32 +97,32 @@ func TestDepartmentHTTPPersistenceAndPermissions(t *testing.T) { if persisted.DepartmentID != childID { t.Fatal("assignment not persisted") } - _, nonempty = call("DELETE", fmt.Sprintf("/api/department/%d/delete", childID), "admin", nil) + _, nonempty = call("DELETE", fmt.Sprintf("/api/department/%d/delete", childID), "super", nil) if nonempty["code"] == float64(0) { t.Fatal("deleted department with member") } - _, cleared := call("PUT", fmt.Sprintf("/api/department/user/%d", user.ID), "admin", map[string]int{"department_id": 0}) + _, cleared := call("PUT", fmt.Sprintf("/api/department/user/%d", user.ID), "super", map[string]int{"department_id": 0}) if cleared["code"] != float64(0) { t.Fatal(cleared) } for _, n := range []int{childID, id} { - _, deleted := call("DELETE", fmt.Sprintf("/api/department/%d/delete", n), "admin", nil) + _, deleted := call("DELETE", fmt.Sprintf("/api/department/%d/delete", n), "super", nil) if deleted["code"] != float64(0) { t.Fatal(deleted) } } for attempt := 0; attempt < 2; attempt++ { - _, again := call("POST", "/api/department/create", "admin", map[string]any{"name": "Reusable"}) + _, again := call("POST", "/api/department/create", "super", map[string]any{"name": "Reusable"}) if again["code"] != float64(0) { t.Fatal("deleted sibling name could not be reused") } n := int(again["data"].(map[string]any)["id"].(float64)) - _, removed := call("DELETE", fmt.Sprintf("/api/department/%d/delete", n), "admin", nil) + _, removed := call("DELETE", fmt.Sprintf("/api/department/%d/delete", n), "super", nil) if removed["code"] != float64(0) { t.Fatal("repeated soft deletion collided") } } - _, listed := call("GET", "/api/department/list", "admin", nil) + _, listed := call("GET", "/api/department/list", "super", nil) if listed["data"].(map[string]any)["total"] != float64(0) { t.Fatal("deleted departments remain listed") } diff --git a/internal/system/api/role_handler.go b/internal/system/api/role_handler.go index acf8de0c..3d8dad5f 100644 --- a/internal/system/api/role_handler.go +++ b/internal/system/api/role_handler.go @@ -26,11 +26,15 @@ package api import ( + "errors" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type RoleHandler struct { @@ -166,7 +170,15 @@ func (h *RoleHandler) AssignRolesToUser(ctx *gin.Context) { user := ctx.MustGet("user").(jwt.UserClaims) - base.HandleRequest(ctx, &req, func() (interface{}, error) { + if err := base.BindRequest(ctx, &req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") + return + } + if !h.guardSuperAdminRoles(ctx, req.UserID, req.RoleIds) { + return + } + + base.HandleRequest(ctx, nil, func() (interface{}, error) { return nil, h.svc.AssignRolesToUser(ctx.Request.Context(), req.UserID, req.RoleIds, user.Uid) }) } @@ -175,11 +187,57 @@ func (h *RoleHandler) AssignRolesToUser(ctx *gin.Context) { func (h *RoleHandler) RevokeRolesFromUser(ctx *gin.Context) { var req model.RevokeRolesFromUserRequest - base.HandleRequest(ctx, &req, func() (interface{}, error) { + if err := base.BindRequest(ctx, &req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") + return + } + if !h.guardSuperAdminRoles(ctx, req.UserID, req.RoleIds) { + return + } + + base.HandleRequest(ctx, nil, func() (interface{}, error) { return nil, h.svc.RevokeRolesFromUser(ctx.Request.Context(), req.UserID, req.RoleIds) }) } +// guardSuperAdminRoles 授予或撤销内置超管角色只能由超管操作,防止持有角色分配 +// 权限的普通用户把自己(或他人)提升为超管、或撤销超管。roleIds 为空时 DAO 会撤销 +// 目标用户的全部角色,因此按目标用户当前角色判断。返回 false 时已写出响应。 +func (h *RoleHandler) guardSuperAdminRoles(ctx *gin.Context, userID int, roleIds []int) bool { + if permission.IsSuperAdmin(ctx) { + return true + } + touches := false + if len(roleIds) == 0 { + roles, err := h.svc.GetUserRoles(ctx.Request.Context(), userID) + if err != nil { + base.ErrorWithMessage(ctx, "获取用户角色失败") + return false + } + if roles != nil { + for _, role := range roles.Items { + touches = touches || permission.IsSuperAdminIdentity(role) + } + } + } + for _, id := range roleIds { + role, err := h.svc.GetRoleByID(ctx.Request.Context(), id) + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + base.ErrorWithMessage(ctx, "获取角色失败") + return false + } + touches = touches || permission.IsSuperAdminIdentity(role) + } + if touches { + base.ForbiddenError(ctx, "仅超级管理员可授予或撤销超级管理员角色") + return false + } + return true +} + func (h *RoleHandler) GetRoleUsers(ctx *gin.Context) { var req model.GetRoleUsersRequest diff --git a/internal/system/api/superadmin_guard_test.go b/internal/system/api/superadmin_guard_test.go new file mode 100644 index 00000000..f44fb78f --- /dev/null +++ b/internal/system/api/superadmin_guard_test.go @@ -0,0 +1,152 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + superRoleID = 1 + memberRoleID = 2 + fakeRoleID = 3 // 普通角色借用 super_admin 编码 +) + +type guardRoles struct { + service.RoleService + targetRoles []*model.Role + assigned *[]int + revoked *[]int +} + +func (guardRoles) GetRoleByID(_ context.Context, id int) (*model.Role, error) { + switch id { + case superRoleID: + return &model.Role{Model: model.Model{ID: id}, Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 0}, nil + case memberRoleID: + return &model.Role{Model: model.Model{ID: id}, Code: "member", Status: 1}, nil + case fakeRoleID: + return &model.Role{Model: model.Model{ID: id}, Code: permission.SuperAdminRoleCode, IsSystem: 0, Status: 1}, nil + } + return nil, gorm.ErrRecordNotFound +} +func (r guardRoles) GetUserRoles(context.Context, int) (*model.ListResp[*model.Role], error) { + return &model.ListResp[*model.Role]{Items: r.targetRoles}, nil +} +func (r guardRoles) AssignRolesToUser(_ context.Context, _ int, roleIds []int, _ int) error { + *r.assigned = append(*r.assigned, roleIds...) + return nil +} +func (r guardRoles) RevokeRolesFromUser(_ context.Context, _ int, roleIds []int) error { + *r.revoked = append(*r.revoked, -1) + return nil +} + +func TestOnlySuperAdminCanGrantOrRevokeSuperAdminRole(t *testing.T) { + superTarget := []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 1}} + for _, tt := range []struct { + name string + path string + roleIds []int + targetRoles []*model.Role + callerSuper bool + allowed bool + }{ + {"assign member role", "/api/role/assign_users", []int{memberRoleID}, nil, false, true}, + {"assign custom role reusing code", "/api/role/assign_users", []int{fakeRoleID}, nil, false, true}, + // 即使超管角色被禁用,授予它也必须由超管完成,否则被重新启用时会立刻提权 + {"assign super role by ordinary", "/api/role/assign_users", []int{memberRoleID, superRoleID}, nil, false, false}, + {"assign super role by super", "/api/role/assign_users", []int{superRoleID}, nil, true, true}, + {"assign unknown role", "/api/role/assign_users", []int{99}, nil, false, true}, + {"revoke super role by ordinary", "/api/role/revoke_users", []int{superRoleID}, nil, false, false}, + {"revoke all roles of super admin by ordinary", "/api/role/revoke_users", []int{}, superTarget, false, false}, + {"revoke all roles of member by ordinary", "/api/role/revoke_users", []int{}, nil, false, true}, + {"revoke super role by super", "/api/role/revoke_users", []int{superRoleID}, nil, true, true}, + } { + t.Run(tt.name, func(t *testing.T) { + var assigned, revoked []int + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}) + permission.MarkSuperAdmin(c, tt.callerSuper) + c.Next() + }) + NewRoleHandler(guardRoles{targetRoles: tt.targetRoles, assigned: &assigned, revoked: &revoked}).RegisterRouters(router) + body, _ := json.Marshal(map[string]any{"user_id": 8, "role_ids": tt.roleIds}) + req := httptest.NewRequest("POST", tt.path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + called := len(assigned)+len(revoked) > 0 + if called != tt.allowed { + t.Fatalf("service called=%v want %v (status=%d body=%s)", called, tt.allowed, rec.Code, rec.Body.String()) + } + if !tt.allowed && rec.Code != 403 { + t.Fatalf("status=%d want 403", rec.Code) + } + }) + } +} + +type profileRecorder struct { + service.UserService + called bool + allowAccount bool +} + +func (p *profileRecorder) UpdateProfile(_ context.Context, _ *model.UpdateProfileReq, allow bool) error { + p.called, p.allowAccount = true, allow + return nil +} + +func TestUpdateProfilePrivilegeComesFromSuperAdminRole(t *testing.T) { + for _, tt := range []struct { + name string + username string + super bool + target int + allowed bool + allowAccount bool + }{ + {"self edit", "member", false, 7, true, false}, + {"other user by ordinary", "member", false, 8, false, false}, + {"other user by username admin without role", "admin", false, 8, false, false}, + {"self edit by username admin without role", "admin", false, 7, true, false}, + {"other user by super admin", "ops-lead", true, 8, true, true}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := &profileRecorder{} + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user", jwt.UserClaims{Uid: 7, Username: tt.username}) + permission.MarkSuperAdmin(c, tt.super) + c.Next() + }) + NewUserHandler(svc, nil).RegisterRoutes(router) + body, _ := json.Marshal(map[string]any{"real_name": "n", "mobile": "1", "account_type": 1, "home_path": "/", "enable": 2}) + req := httptest.NewRequest("PUT", fmt.Sprintf("/api/user/profile/update/%d", tt.target), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if svc.called != tt.allowed { + t.Fatalf("called=%v want %v (status=%d)", svc.called, tt.allowed, rec.Code) + } + if !tt.allowed && rec.Code != 403 { + t.Fatalf("status=%d want 403", rec.Code) + } + if svc.allowAccount != tt.allowAccount { + t.Fatalf("allowAccountChanges=%v want %v", svc.allowAccount, tt.allowAccount) + } + }) + } +} diff --git a/internal/system/api/user_handler.go b/internal/system/api/user_handler.go index 7a11e8b7..2c280955 100644 --- a/internal/system/api/user_handler.go +++ b/internal/system/api/user_handler.go @@ -31,6 +31,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" userutils "github.com/GoSimplicity/AI-CloudOps/internal/system/utils" "github.com/GoSimplicity/AI-CloudOps/pkg/base" @@ -223,13 +224,14 @@ func (h *UserHandler) UpdateProfile(ctx *gin.Context) { } req.ID = id - if uc.Username != "admin" && uc.Uid != req.ID { + super := permission.IsSuperAdmin(ctx) + if !super && uc.Uid != req.ID { base.ForbiddenError(ctx, "无权限修改该用户信息") return } base.HandleRequest(ctx, &req, func() (interface{}, error) { - return nil, h.service.UpdateProfile(ctx.Request.Context(), &req, uc.Username == "admin") + return nil, h.service.UpdateProfile(ctx.Request.Context(), &req, super) }) } From 38a0cdf01d55a4a96f06bcad3d42b8f397c4588f Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:01:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(auth):=20SEC-24=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=96=B9=E6=B3=95+gin=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E7=B2=BE=E7=A1=AE=E6=AF=94=E8=BE=83=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9D=83=E9=99=90=E7=A0=81v2=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CheckAuth 以 c.FullPath() 模板 + 方法与角色授予比较,:param 不再作为通配; /* 与末尾 * 只对模板做前缀匹配,开头/中间/多个 * 的授予不再生效 - 未命中路由(FullPath 为空)对非超管返回 403,超管交给 gin 返回 404 - AIOps 代理 /api/v1/*path 由 registerAIOps 登记内置接口目录,按最具体模板 (静态段优先)还原后再比较;目录外请求只认覆盖代理模板本身的授予 - 新增 GET /api/user/permissions:返回 {version:2, super_admin, permissions}, 权限码为 "METHOD /模板",与中间件同源展开;超管返回 ["*"];/api/user/codes 不变 - 回归测试使用真实 handler 路由注册,覆盖三处外溢: events/:namespace/:name vs events/nodes/:node_name、 cloud/account/:id/status vs account/batch/status、cloud/:id/status vs cloud/batch/status --- internal/app/aiops.go | 9 +- internal/app/aiops_catalog_test.go | 105 +++++++++ internal/app/permissions.go | 28 ++- internal/middleware/auth.go | 115 ++-------- internal/middleware/auth_regression_test.go | 217 +++++++++++++++++++ internal/middleware/auth_test.go | 15 ++ internal/model/system_permission.go | 12 + internal/permission/catalog.go | 112 ++++++++++ internal/permission/matcher.go | 83 +++++++ internal/permission/matcher_test.go | 174 +++++++++++++++ internal/permission/proxy.go | 100 +++++++++ internal/system/api/user_handler.go | 24 ++ internal/system/api/user_permissions_test.go | 213 ++++++++++++++++++ internal/system/service/user_service.go | 19 ++ 14 files changed, 1122 insertions(+), 104 deletions(-) create mode 100644 internal/app/aiops_catalog_test.go create mode 100644 internal/middleware/auth_regression_test.go create mode 100644 internal/model/system_permission.go create mode 100644 internal/permission/catalog.go create mode 100644 internal/permission/matcher.go create mode 100644 internal/permission/matcher_test.go create mode 100644 internal/permission/proxy.go create mode 100644 internal/system/api/user_permissions_test.go diff --git a/internal/app/aiops.go b/internal/app/aiops.go index fef9ba3d..7d6f7936 100644 --- a/internal/app/aiops.go +++ b/internal/app/aiops.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" jwt "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" ws "github.com/GoSimplicity/AI-CloudOps/pkg/websocket" "net/http" @@ -61,7 +62,11 @@ func (a *App) registerAIOps() error { http.Error(w, `{"code":502,"message":"AI service unavailable"}`, http.StatusBadGateway) }, } - a.server.Any("/api/v1/*path", func(c *gin.Context) { + catalog, err := parseAIOpsCatalog() + if err != nil { + return err + } + a.server.Any(permission.AIOpsProxyTemplate, func(c *gin.Context) { value, exists := c.Get("user") claims, valid := value.(jwt.UserClaims) if !exists || !valid || claims.Uid <= 0 { @@ -71,5 +76,7 @@ func (a *App) registerAIOps() error { request := c.Request.WithContext(context.WithValue(c.Request.Context(), aiopsPrincipalKey{}, strconv.Itoa(claims.Uid))) proxy.ServeHTTP(c.Writer, request) }) + // 代理只有一个通配模板,鉴权需要按目录还原出具体接口模板 + permission.SetProxyCatalog(permission.AIOpsProxyTemplate, catalog) return nil } diff --git a/internal/app/aiops_catalog_test.go b/internal/app/aiops_catalog_test.go new file mode 100644 index 00000000..c4e91757 --- /dev/null +++ b/internal/app/aiops_catalog_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/config" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +type grantRoles struct { + service.RoleService + apis []*model.Api +} + +func (g grantRoles) GetUserRoles(_ context.Context, _ int) (*model.ListResp[*model.Role], error) { + return &model.ListResp[*model.Role]{Items: []*model.Role{{Status: 1, Apis: g.apis}}, Total: 1}, nil +} + +func TestEmbeddedAIOpsCatalogResolvesEveryEntryToItself(t *testing.T) { + routes, err := parseAIOpsCatalog() + if err != nil { + t.Fatal(err) + } + if len(routes) == 0 { + t.Fatal("empty AIOps catalog") + } + permission.SetProxyCatalog(permission.AIOpsProxyTemplate, routes) + t.Cleanup(func() { permission.SetProxyCatalog(permission.AIOpsProxyTemplate, nil) }) + for _, route := range routes { + concrete := route.Path + for _, part := range strings.Split(route.Path, "/") { + if strings.HasPrefix(part, ":") { + concrete = strings.Replace(concrete, part, "sample-"+part[1:], 1) + } + } + if got := permission.ResolveProxyTemplate(route.Method, permission.AIOpsProxyTemplate, concrete); got != route.Path { + t.Errorf("%s %s resolved to %q", route.Method, concrete, got) + } + } +} + +func TestRegisterAIOpsInstallsAuthorizationCatalog(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + defer upstream.Close() + t.Cleanup(func() { permission.SetProxyCatalog(permission.AIOpsProxyTemplate, nil) }) + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7}); c.Next() }) + engine.Use(middleware.NewAuthMiddleware(grantRoles{apis: []*model.Api{ + {Method: 1, Path: "/api/v1/assistant/session/:session_id"}, + {Method: 1, Path: "/api/v1/inspection/report/:report_id"}, + }}).CheckAuth()) + a := &App{cfg: &config.Config{AIOps: config.AIOpsConfig{BaseURL: upstream.URL, APIKey: "test-only"}}, server: engine} + if err := a.registerAIOps(); err != nil { + t.Fatal(err) + } + // 用真实 HTTP 服务承载网关:ReverseProxy 需要 CloseNotifier,ResponseRecorder 不提供 + gateway := httptest.NewServer(engine) + defer gateway.Close() + for _, tt := range []struct { + method, path string + want int + }{ + {"GET", "/api/v1/assistant/session/s-1", 200}, + {"GET", "/api/v1/inspection/report/r-1", 200}, + {"GET", "/api/v1/inspection/report/r-1/markdown", 403}, + {"DELETE", "/api/v1/assistant/session/s-1", 403}, + {"GET", "/api/v1/assistant/session/s-1/extra", 403}, + {"POST", "/api/v1/autofix", 403}, + } { + req, err := http.NewRequest(tt.method, gateway.URL+tt.path, nil) + if err != nil { + t.Fatal(err) + } + resp, err := gateway.Client().Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != tt.want { + t.Errorf("%s %s: status=%d want=%d", tt.method, tt.path, resp.StatusCode, tt.want) + } + } +} + +func TestRegisterAIOpsWithoutBaseURLLeavesNoCatalog(t *testing.T) { + permission.SetProxyCatalog(permission.AIOpsProxyTemplate, nil) + a := &App{cfg: &config.Config{}, server: gin.New()} + if err := a.registerAIOps(); err != nil { + t.Fatal(err) + } + if got := permission.ProxyCatalog(permission.AIOpsProxyTemplate); len(got) != 0 { + t.Fatalf("catalog installed without proxy: %d entries", len(got)) + } +} diff --git a/internal/app/permissions.go b/internal/app/permissions.go index eb2cd5bd..603b609a 100644 --- a/internal/app/permissions.go +++ b/internal/app/permissions.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/gin-gonic/gin" "gorm.io/gorm/clause" ) @@ -16,6 +17,22 @@ import ( //go:embed aiops_permissions.json var aiopsPermissionCatalog []byte +// parseAIOpsCatalog 解析内嵌的 AI 接口目录。该目录既用于补充接口元数据, +// 也登记为 /api/v1/*path 代理的鉴权目录(见 permission.ResolveProxyTemplate)。 +func parseAIOpsCatalog() ([]permission.Route, error) { + var routes []permission.Route + if err := json.Unmarshal(aiopsPermissionCatalog, &routes); err != nil { + return nil, fmt.Errorf("解析AI接口权限目录: %w", err) + } + prefix := strings.TrimSuffix(permission.AIOpsProxyTemplate, "*path") + for _, route := range routes { + if _, ok := permission.MethodCode(route.Method); !ok || !strings.HasPrefix(route.Path, prefix) { + return nil, fmt.Errorf("AI接口权限目录包含非法条目: %s %s", route.Method, route.Path) + } + } + return routes, nil +} + // bootstrapPermissionCatalog 补充可发现接口,不授予角色或覆盖管理员维护的元数据 func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { methods := map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} @@ -31,13 +48,10 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { } routes := a.server.Routes() for _, route := range routes { - if route.Path == "/api/v1/*path" { - var aiRoutes []struct { - Method string `json:"method"` - Path string `json:"path"` - } - if err := json.Unmarshal(aiopsPermissionCatalog, &aiRoutes); err != nil { - return fmt.Errorf("解析AI接口权限目录: %w", err) + if route.Path == permission.AIOpsProxyTemplate { + aiRoutes, err := parseAIOpsCatalog() + if err != nil { + return err } for _, aiRoute := range aiRoutes { routes = append(routes, gin.RouteInfo{Method: aiRoute.Method, Path: aiRoute.Path, Handler: "AIOpsProxy"}) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f13eaeb0..a02d787f 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -26,8 +26,6 @@ package middleware import ( - "strings" - "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" @@ -36,25 +34,6 @@ import ( "github.com/gin-gonic/gin" ) -var skipAuthPaths = map[string]bool{ - "/api/user/login": true, - "/api/user/logout": true, - "/api/user/refresh_token": true, - "/api/user/profile": true, - "/api/user/codes": true, - "/api/not_auth/getBindIps": true, - "/api/not_auth/getTreeNodeBindIps": true, - "/favicon.ico": true, -} - -// HTTP方法映射 -var methodMapping = map[string]int8{ - "GET": 1, - "POST": 2, - "PUT": 3, - "DELETE": 4, -} - type AuthMiddleware struct { roleService service.RoleService } @@ -65,66 +44,14 @@ func NewAuthMiddleware(roleService service.RoleService) *AuthMiddleware { } } -func matchWildcardPath(apiPath, requestPath string, methodCode int8, apiMethod int8) bool { - // 方法不匹配则返回false - if apiMethod != methodCode { - return false - } - - // 完全匹配 - if apiPath == requestPath { - return true - } - - // 全局通配符匹配所有路径 - if apiPath == "/*" { - return true - } - - if !strings.Contains(apiPath, "*") { - // 代理入口只有通配路由,目录中的参数必须严格占用一个非空路径段 - granted, requested := strings.Split(apiPath, "/"), strings.Split(requestPath, "/") - if len(granted) != len(requested) { - return false - } - for index, part := range granted { - if strings.HasPrefix(part, ":") && len(part) > 1 { - if requested[index] == "" || requested[index] == "." || requested[index] == ".." { - return false - } - } else if part != requested[index] { - return false - } - } - return true - } - - // 末尾通配符:/api/user/* - if strings.HasSuffix(apiPath, "*") { - prefix := strings.TrimSuffix(apiPath, "*") - return strings.HasPrefix(requestPath, prefix) - } - - // 开头通配符:*/logs - if strings.HasPrefix(apiPath, "*") { - suffix := strings.TrimPrefix(apiPath, "*") - return strings.HasSuffix(requestPath, suffix) - } - - // 中间通配符:/api/*/logs - if strings.Count(apiPath, "*") == 1 { - parts := strings.Split(apiPath, "*") - return strings.HasPrefix(requestPath, parts[0]) && strings.HasSuffix(requestPath, parts[1]) - } - - return false -} - +// CheckAuth 按“HTTP 方法 + gin 路由模板(c.FullPath())”对角色授予做精确比较。 +// 授予中的 /* 与末尾 * 只对模板做前缀匹配,:param 不再被当作通配符; +// 匹配规则见 internal/permission 与 docs/api/user-permissions-v2.md。 func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path - if skipAuthPaths[path] { + if permission.SkipsAuthorization(path) { c.Next() return } @@ -138,11 +65,6 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { // 获取用户信息(兼容nginx代理) userVal, exists := c.Get("user") if !exists { - // 兼容未登录时放行登录接口 - if skipAuthPaths[path] { - c.Next() - return - } base.ForbiddenError(c, "未登录或登录已过期") c.Abort() return @@ -173,7 +95,7 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { return } - // 超级管理员放行 + // 超级管理员放行(未命中路由时交给 gin 返回 404) if super { c.Next() return @@ -181,26 +103,27 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { // 获取HTTP方法代码 method := c.Request.Method - methodCode, exists := methodMapping[method] + methodCode, exists := permission.MethodCode(method) if !exists { base.ErrorWithMessage(c, "不支持的HTTP方法") c.Abort() return } - for _, role := range roles.Items { - // 跳过禁用角色 - if role.Status != 1 { - continue - } + // 未命中任何路由(404)时 FullPath 为空:对非超管统一返回 403, + // 既不放行也不暴露路由是否存在。 + fullPath := c.FullPath() + if fullPath == "" { + base.ForbiddenError(c, "无权限访问该接口") + c.Abort() + return + } - // 检查API权限 - for _, api := range role.Apis { - if (api.Path == c.FullPath() && api.Method == methodCode) || matchWildcardPath(api.Path, path, methodCode, api.Method) { - c.Next() - return - } - } + // 代理通配入口(AIOps)借助接口目录把真实路径还原为具体模板 + resolved := permission.ResolveProxyTemplate(method, fullPath, path) + if permission.Allowed(permission.GrantsFromRoles(roles.Items), methodCode, fullPath, resolved) { + c.Next() + return } // 无权限访问 diff --git a/internal/middleware/auth_regression_test.go b/internal/middleware/auth_regression_test.go new file mode 100644 index 00000000..113c5e47 --- /dev/null +++ b/internal/middleware/auth_regression_test.go @@ -0,0 +1,217 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + k8sapi "github.com/GoSimplicity/AI-CloudOps/internal/k8s/api" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + treeapi "github.com/GoSimplicity/AI-CloudOps/internal/tree/api" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +// productionRouter 用真实的 handler RegisterRouters 注册路由(与线上同一份路由表), +// 在鉴权之后用一个终止中间件代替业务 handler:放行返回 204 并记录命中的模板, +// 拒绝则由鉴权中间件返回 403。 +func productionRouter(t *testing.T, grants []*model.Api) (*gin.Engine, *string) { + t.Helper() + gin.SetMode(gin.TestMode) + engine := gin.New() + matched := new(string) + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 42, Username: "operator"}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: []*model.Role{{Status: 1, Apis: grants}}}).CheckAuth()) + engine.Use(func(c *gin.Context) { *matched = c.FullPath(); c.AbortWithStatus(204) }) + k8sapi.NewK8sEventHandler(nil).RegisterRouters(engine) + treeapi.NewCloudAccountHandler(nil).RegisterRouters(engine) + treeapi.NewTreeCloudHandler(nil, nil, nil, nil).RegisterRouters(engine) + return engine, matched +} + +func TestParamGrantsDoNotLeakToSiblingStaticRoutes(t *testing.T) { + for _, tt := range []struct { + name string + grant model.Api + method string + allowed string // 授予本身覆盖的请求 + allowedTmpl string + leaked string // 改动前会被授予外溢放行的请求 + leakedTmpl string + }{ + { + name: "k8s event detail vs node events (event.go:49 vs :54)", + grant: model.Api{Method: 1, Path: "/api/k8s/clusters/:cluster_id/events/:namespace/:name"}, + method: "GET", + allowed: "/api/k8s/clusters/1/events/default/pod-oom.17a", + allowedTmpl: "/api/k8s/clusters/:cluster_id/events/:namespace/:name", + leaked: "/api/k8s/clusters/1/events/nodes/worker-1", + leakedTmpl: "/api/k8s/clusters/:cluster_id/events/nodes/:node_name", + }, + { + name: "cloud account status vs batch status (cloud_account_handler.go:54 vs :57)", + grant: model.Api{Method: 3, Path: "/api/tree/cloud/account/:id/status"}, + method: "PUT", + allowed: "/api/tree/cloud/account/7/status", + allowedTmpl: "/api/tree/cloud/account/:id/status", + leaked: "/api/tree/cloud/account/batch/status", + leakedTmpl: "/api/tree/cloud/account/batch/status", + }, + { + name: "cloud resource status vs batch status (tree_cloud_handler.go:69 vs :74)", + grant: model.Api{Method: 3, Path: "/api/tree/cloud/:id/status"}, + method: "PUT", + allowed: "/api/tree/cloud/9/status", + allowedTmpl: "/api/tree/cloud/:id/status", + leaked: "/api/tree/cloud/batch/status", + leakedTmpl: "/api/tree/cloud/batch/status", + }, + } { + t.Run(tt.name, func(t *testing.T) { + grant := tt.grant + engine, matched := productionRouter(t, []*model.Api{&grant}) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.allowed, nil)) + if rec.Code != 204 || *matched != tt.allowedTmpl { + t.Fatalf("granted request: status=%d template=%q", rec.Code, *matched) + } + + // 确认泄露请求确实命中的是兄弟静态路由,而不是 404 + *matched = "" + rec = httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.leaked, nil)) + if rec.Code != 403 { + t.Fatalf("sibling route %s leaked: status=%d", tt.leakedTmpl, rec.Code) + } + if !routeRegistered(engine, tt.method, tt.leakedTmpl) { + t.Fatalf("test setup: %s %s is not registered", tt.method, tt.leakedTmpl) + } + + // 显式授予兄弟路由后才放行,说明拒绝来自授权而非路由 + sibling := model.Api{Method: grant.Method, Path: tt.leakedTmpl} + engine, matched = productionRouter(t, []*model.Api{&grant, &sibling}) + rec = httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.leaked, nil)) + if rec.Code != 204 || *matched != tt.leakedTmpl { + t.Fatalf("explicit sibling grant: status=%d template=%q", rec.Code, *matched) + } + }) + } +} + +func routeRegistered(engine *gin.Engine, method, path string) bool { + for _, route := range engine.Routes() { + if route.Method == method && route.Path == path { + return true + } + } + return false +} + +func TestWildcardGrantsMatchTemplatesOnly(t *testing.T) { + for _, tt := range []struct { + name string + grant model.Api + method string + path string + want int + }{ + {"global wildcard", model.Api{Method: 1, Path: "/*"}, "GET", "/api/k8s/clusters/1/events/nodes/worker-1", 204}, + {"global wildcard keeps method", model.Api{Method: 1, Path: "/*"}, "PUT", "/api/tree/cloud/batch/status", 403}, + {"module prefix", model.Api{Method: 3, Path: "/api/tree/cloud/account/*"}, "PUT", "/api/tree/cloud/account/batch/status", 204}, + {"module prefix does not reach parent module", model.Api{Method: 3, Path: "/api/tree/cloud/account/*"}, "PUT", "/api/tree/cloud/batch/status", 403}, + // 前缀只作用于模板:真实路径里的值不能“拼”出被授予的前缀 + {"prefix on real id is not honoured", model.Api{Method: 3, Path: "/api/tree/cloud/9/*"}, "PUT", "/api/tree/cloud/9/status", 403}, + {"prefix written with template param", model.Api{Method: 3, Path: "/api/tree/cloud/:id/*"}, "PUT", "/api/tree/cloud/9/status", 204}, + {"prefix with param does not cover static sibling", model.Api{Method: 3, Path: "/api/tree/cloud/:id/*"}, "PUT", "/api/tree/cloud/batch/status", 403}, + {"exact real path is not a template", model.Api{Method: 3, Path: "/api/tree/cloud/9/status"}, "PUT", "/api/tree/cloud/9/status", 403}, + {"leading wildcard dropped", model.Api{Method: 3, Path: "*/status"}, "PUT", "/api/tree/cloud/9/status", 403}, + {"middle wildcard dropped", model.Api{Method: 3, Path: "/api/tree/*/status"}, "PUT", "/api/tree/cloud/:id/status", 403}, + } { + t.Run(tt.name, func(t *testing.T) { + grant := tt.grant + engine, _ := productionRouter(t, []*model.Api{&grant}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestUnmatchedRouteIsForbiddenForOrdinaryUsersAndNotFoundForSuperAdmin(t *testing.T) { + for _, tt := range []struct { + name string + roles []*model.Role + want int + }{ + {"global wildcard grant", []*model.Role{{Status: 1, Apis: []*model.Api{{Method: 1, Path: "/*"}}}}, 403}, + {"no roles", nil, 403}, + {"super admin", []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 1}}, 404}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: tt.roles}).CheckAuth()) + engine.GET("/api/item/:id", func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/no-such-route", nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestProxyGrantsResolveToMostSpecificCatalogTemplate(t *testing.T) { + useProxyCatalog(t, []permission.Route{ + {Method: "GET", Path: "/api/v1/inspection/report/:report_id"}, + {Method: "GET", Path: "/api/v1/inspection/report/latest"}, + {Method: "GET", Path: "/api/v1/inspection/report/:report_id/markdown"}, + {Method: "POST", Path: "/api/v1/autofix"}, + {Method: "POST", Path: "/api/v1/autofix/"}, + }) + for _, tt := range []struct { + name string + grants []*model.Api + method string + path string + want int + }{ + {"param grant", []*model.Api{{Method: 1, Path: "/api/v1/inspection/report/:report_id"}}, "GET", "/api/v1/inspection/report/r-1", 204}, + {"param grant does not reach static sibling", []*model.Api{{Method: 1, Path: "/api/v1/inspection/report/:report_id"}}, "GET", "/api/v1/inspection/report/latest", 403}, + {"param grant does not reach deeper template", []*model.Api{{Method: 1, Path: "/api/v1/inspection/report/:report_id"}}, "GET", "/api/v1/inspection/report/r-1/markdown", 403}, + {"static grant", []*model.Api{{Method: 1, Path: "/api/v1/inspection/report/latest"}}, "GET", "/api/v1/inspection/report/latest", 204}, + {"prefix grant", []*model.Api{{Method: 1, Path: "/api/v1/inspection/*"}}, "GET", "/api/v1/inspection/report/r-1/markdown", 204}, + {"prefix grant ignores uncatalogued endpoints", []*model.Api{{Method: 1, Path: "/api/v1/inspection/*"}}, "GET", "/api/v1/inspection/internal/debug", 403}, + {"module grant covers uncatalogued endpoints", []*model.Api{{Method: 1, Path: "/api/v1/*path"}}, "GET", "/api/v1/inspection/internal/debug", 204}, + {"trailing slash alias is distinct", []*model.Api{{Method: 2, Path: "/api/v1/autofix"}}, "POST", "/api/v1/autofix/", 403}, + {"trailing slash alias granted", []*model.Api{{Method: 2, Path: "/api/v1/autofix/"}}, "POST", "/api/v1/autofix/", 204}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: []*model.Role{{Status: 1, Apis: tt.grants}}}).CheckAuth()) + engine.Any(permission.AIOpsProxyTemplate, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestDisabledRoleGrantsNothing(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7}); c.Next() }) + engine.Use(NewAuthMiddleware(fixedRoles{roles: []*model.Role{{Status: 0, Apis: []*model.Api{{Method: 1, Path: "/*"}}}}}).CheckAuth()) + engine.GET("/api/item/:id", func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/item/1", nil)) + if rec.Code != 403 { + t.Fatalf("status=%d", rec.Code) + } +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 3ac215ea..0532ad7c 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" "github.com/gin-gonic/gin" @@ -44,6 +45,13 @@ func (r aiProxyRoles) GetUserRoles(context.Context, int) (*model.ListResp[*model return &model.ListResp[*model.Role]{Items: []*model.Role{{Status: 1, Apis: r.apis}}}, nil } func TestAssistantProxyPermissionsDoNotGrantInfrastructureActions(t *testing.T) { + // 代理入口只有通配模板,鉴权依赖启动时登记的 AIOps 接口目录(真实目录见 internal/app 的测试) + useProxyCatalog(t, []permission.Route{ + {Method: "POST", Path: "/api/v1/assistant/query"}, + {Method: "GET", Path: "/api/v1/assistant/session/:session_id"}, + {Method: "POST", Path: "/api/v1/autofix/workflow/confirm"}, + {Method: "POST", Path: "/api/v1/autofix/notify"}, + }) grants := []*model.Api{{Path: "/api/v1/assistant/query", Method: 2}, {Path: "/api/v1/assistant/session/:session_id", Method: 1}} for _, test := range []struct { method, path string @@ -73,6 +81,13 @@ func TestAssistantProxyPermissionsDoNotGrantInfrastructureActions(t *testing.T) } } +func useProxyCatalog(t *testing.T, routes []permission.Route) { + t.Helper() + previous := permission.ProxyCatalog(permission.AIOpsProxyTemplate) + permission.SetProxyCatalog(permission.AIOpsProxyTemplate, routes) + t.Cleanup(func() { permission.SetProxyCatalog(permission.AIOpsProxyTemplate, previous) }) +} + func TestLegacyModuleGrantRemainsExplicitlyBroad(t *testing.T) { engine := gin.New() engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}); c.Next() }) diff --git a/internal/model/system_permission.go b/internal/model/system_permission.go new file mode 100644 index 00000000..906ca8dd --- /dev/null +++ b/internal/model/system_permission.go @@ -0,0 +1,12 @@ +package model + +// UserPermissionsVersion 是 GET /api/user/permissions 响应格式的版本号。 +const UserPermissionsVersion = 2 + +// UserPermissionsResp 是 GET /api/user/permissions 的响应数据(契约见 docs/api/user-permissions-v2.md)。 +// 仅用于接口响应,不是数据库模型,不参与 AutoMigrate。 +type UserPermissionsResp struct { + Version int `json:"version"` + SuperAdmin bool `json:"super_admin"` + Permissions []string `json:"permissions"` +} diff --git a/internal/permission/catalog.go b/internal/permission/catalog.go new file mode 100644 index 00000000..7d226ac1 --- /dev/null +++ b/internal/permission/catalog.go @@ -0,0 +1,112 @@ +package permission + +import ( + "sort" + "strings" + + "github.com/gin-gonic/gin" +) + +var skipAuthorizationPaths = map[string]bool{ + "/api/user/login": true, + "/api/user/logout": true, + "/api/user/refresh_token": true, + "/api/user/profile": true, + "/api/user/codes": true, + "/api/user/permissions": true, + "/api/not_auth/getBindIps": true, + "/api/not_auth/getTreeNodeBindIps": true, + "/favicon.ico": true, +} + +// SkipsAuthorization 判断路径是否不经角色授权(登录态仍由登录中间件负责)。 +func SkipsAuthorization(path string) bool { + return skipAuthorizationPaths[path] +} + +// RequiresSuperAdmin 判断路由模板是否只允许超管访问(角色授予对其无效)。 +func RequiresSuperAdmin(method, template string) bool { + if method == "POST" && template == "/api/user/signup" { + return true + } + return template == "/api/department" || strings.HasPrefix(template, "/api/department/") +} + +// CatalogEntry 是可授权接口目录中的一项。Via 非空表示该接口经由 gin 通配模板 +// (代理)提供,授予覆盖 Via 本身也视为覆盖该接口。 +type CatalogEntry struct { + Method int8 + Path string + Via string +} + +// Code 返回 "METHOD /path/:param" 形式的权限码。 +func (e CatalogEntry) Code() string { + return MethodName(e.Method) + " " + e.Path +} + +// BuildCatalog 根据 gin 已注册路由与已登记的代理目录构建可授权接口目录: +// 只保留 GET/POST/PUT/DELETE、/api/ 前缀、需要角色授权且非超管专属的接口。 +func BuildCatalog(routes gin.RoutesInfo) []CatalogEntry { + seen := map[string]bool{} + var entries []CatalogEntry + add := func(method, path, via string) { + code, ok := MethodCode(method) + if !ok || !strings.HasPrefix(path, "/api/") || SkipsAuthorization(path) || RequiresSuperAdmin(method, path) { + return + } + key := method + " " + path + if seen[key] { + return + } + seen[key] = true + entries = append(entries, CatalogEntry{Method: code, Path: path, Via: via}) + } + var proxies []string + for _, route := range routes { + add(route.Method, route.Path, "") + if !contains(proxies, route.Path) && len(ProxyCatalog(route.Path)) > 0 { + proxies = append(proxies, route.Path) + } + } + for _, template := range proxies { + registered := map[string]bool{} + for _, route := range routes { + if route.Path == template { + registered[route.Method] = true + } + } + for _, route := range ProxyCatalog(template) { + if registered[route.Method] { + add(route.Method, route.Path, template) + } + } + } + return entries +} + +// Expand 把授予展开为目录中被覆盖的权限码,结果已排序去重,且不为 nil。 +// 判定与鉴权中间件一致:方法相同,且授予覆盖接口模板或其代理通配模板。 +func Expand(grants []Grant, catalog []CatalogEntry) []string { + codes := map[string]bool{} + for _, entry := range catalog { + if Allowed(grants, entry.Method, entry.Path) || (entry.Via != "" && Allowed(grants, entry.Method, entry.Via)) { + codes[entry.Code()] = true + } + } + result := make([]string, 0, len(codes)) + for code := range codes { + result = append(result, code) + } + sort.Strings(result) + return result +} + +func contains(items []string, item string) bool { + for _, existing := range items { + if existing == item { + return true + } + } + return false +} diff --git a/internal/permission/matcher.go b/internal/permission/matcher.go new file mode 100644 index 00000000..158e7351 --- /dev/null +++ b/internal/permission/matcher.go @@ -0,0 +1,83 @@ +package permission + +import ( + "strings" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" +) + +// SuperCode 是 /api/user/permissions 返回给超管的唯一权限码,表示全部放行。 +const SuperCode = "*" + +var methodCodes = map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} +var methodNames = map[int8]string{1: "GET", 2: "POST", 3: "PUT", 4: "DELETE"} + +// MethodCode 把 HTTP 方法转换为 cl_system_apis.method 的取值。 +func MethodCode(method string) (int8, bool) { + code, ok := methodCodes[method] + return code, ok +} + +// MethodName 把 cl_system_apis.method 的取值转换为 HTTP 方法;未知取值返回 ""。 +func MethodName(code int8) string { + return methodNames[code] +} + +// Grant 是角色授予的一条接口权限(方法 + 授予路径)。 +type Grant struct { + Method int8 + Path string +} + +// GrantsFromRoles 收集启用中角色的接口授予。 +func GrantsFromRoles(roles []*model.Role) []Grant { + var grants []Grant + for _, role := range roles { + if role == nil || role.Status != 1 { + continue + } + for _, api := range role.Apis { + if api == nil { + continue + } + grants = append(grants, Grant{Method: api.Method, Path: api.Path}) + } + } + return grants +} + +// Covers 判断授予路径是否覆盖某个 gin 路由模板。 +// +// - 模板为空(请求没有命中路由)时永远不覆盖; +// - 与模板完全相同则覆盖(包括 /api/v1/*path 这类 gin 通配模板本身); +// - 只含一个 * 且位于末尾的授予(如 /*、/api/k8s/*)对模板字符串做前缀匹配; +// - 其他写法(开头或中间的 *、多个 *)不覆盖任何模板; +// - :param 只是模板里的字面量,不会被当作通配符。 +func Covers(granted, template string) bool { + if template == "" || granted == "" { + return false + } + if granted == template { + return true + } + if strings.Count(granted, "*") == 1 && strings.HasSuffix(granted, "*") { + return strings.HasPrefix(template, strings.TrimSuffix(granted, "*")) + } + return false +} + +// Allowed 判断授予集合中是否存在方法相同且覆盖任一候选模板的授予。 +// 空模板会被忽略。 +func Allowed(grants []Grant, method int8, templates ...string) bool { + for _, grant := range grants { + if grant.Method != method { + continue + } + for _, template := range templates { + if Covers(grant.Path, template) { + return true + } + } + } + return false +} diff --git a/internal/permission/matcher_test.go b/internal/permission/matcher_test.go new file mode 100644 index 00000000..eb4f71e0 --- /dev/null +++ b/internal/permission/matcher_test.go @@ -0,0 +1,174 @@ +package permission + +import ( + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/gin-gonic/gin" +) + +func TestCoversComparesTemplatesNotRequestPaths(t *testing.T) { + for _, tt := range []struct { + granted, template string + want bool + }{ + {"/api/tree/cloud/:id/status", "/api/tree/cloud/:id/status", true}, + {"/api/tree/cloud/:id/status", "/api/tree/cloud/batch/status", false}, + {"/api/tree/cloud/:id/status", "/api/tree/cloud/9/status", false}, + {"/api/k8s/clusters/:cluster_id/events/:namespace/:name", "/api/k8s/clusters/:cluster_id/events/nodes/:node_name", false}, + {"/*", "/api/anything/:id", true}, + {"*", "/api/anything/:id", true}, // 与改动前一致:单个 * 视为全局前缀 + {"/api/tree/*", "/api/tree/cloud/batch/status", true}, + {"/api/tree/*", "/api/treex/list", false}, + {"/api/tree*", "/api/treex/list", true}, // 前缀按字符串比较,与改动前一致 + {"/api/tree/cloud/:id/*", "/api/tree/cloud/:id/status", true}, + {"/api/tree/cloud/:id/*", "/api/tree/cloud/batch/status", false}, + {"/api/v1/*path", "/api/v1/*path", true}, + {"/api/v1/*", "/api/v1/*path", true}, + {"*/status", "/api/tree/cloud/:id/status", false}, + {"/api/*/status", "/api/tree/cloud/:id/status", false}, + {"/api/**", "/api/tree/list", false}, + {"/api/*/*", "/api/tree/list", false}, + {"", "/api/tree/list", false}, + {"/*", "", false}, + } { + if got := Covers(tt.granted, tt.template); got != tt.want { + t.Errorf("Covers(%q, %q)=%v want %v", tt.granted, tt.template, got, tt.want) + } + } +} + +func TestAllowedRequiresSameMethodAndIgnoresDisabledRoles(t *testing.T) { + grants := GrantsFromRoles([]*model.Role{ + nil, + {Status: 1, Apis: []*model.Api{nil, {Method: 1, Path: "/api/x/:id"}}}, + {Status: 0, Apis: []*model.Api{{Method: 3, Path: "/*"}}}, + }) + if len(grants) != 1 { + t.Fatalf("grants=%v", grants) + } + if !Allowed(grants, 1, "/api/x/:id") || Allowed(grants, 3, "/api/x/:id") || Allowed(grants, 1, "", "") { + t.Fatal("method/empty-template handling broken") + } + if !Allowed(grants, 1, "/api/v1/*path", "/api/x/:id") { + t.Fatal("any candidate template should be enough") + } +} + +func TestMethodCodes(t *testing.T) { + for name, code := range map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} { + if got, ok := MethodCode(name); !ok || got != code || MethodName(code) != name { + t.Fatalf("%s", name) + } + } + if _, ok := MethodCode("PATCH"); ok { + t.Fatal("PATCH has no grant code") + } + if MethodName(9) != "" { + t.Fatal("unknown code") + } +} + +func withProxyCatalog(t *testing.T, routes []Route) { + t.Helper() + previous := ProxyCatalog(AIOpsProxyTemplate) + SetProxyCatalog(AIOpsProxyTemplate, routes) + t.Cleanup(func() { SetProxyCatalog(AIOpsProxyTemplate, previous) }) +} + +func TestResolveProxyTemplatePicksMostSpecific(t *testing.T) { + withProxyCatalog(t, []Route{ + {"GET", "/api/v1/report/:id"}, + {"GET", "/api/v1/report/latest"}, + {"GET", "/api/v1/:kind/latest"}, + {"GET", "/api/v1/report/:id/markdown"}, + {"POST", "/api/v1/autofix"}, + {"POST", "/api/v1/autofix/"}, + }) + for _, tt := range []struct { + method, fullPath, path, want string + }{ + {"GET", AIOpsProxyTemplate, "/api/v1/report/r1", "/api/v1/report/:id"}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/latest", "/api/v1/report/latest"}, + {"GET", AIOpsProxyTemplate, "/api/v1/task/latest", "/api/v1/:kind/latest"}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/r1/markdown", "/api/v1/report/:id/markdown"}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/", ""}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/..", ""}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/.", ""}, + {"GET", AIOpsProxyTemplate, "/api/v1/report/r1/extra", ""}, + {"POST", AIOpsProxyTemplate, "/api/v1/report/r1", ""}, + {"POST", AIOpsProxyTemplate, "/api/v1/autofix", "/api/v1/autofix"}, + {"POST", AIOpsProxyTemplate, "/api/v1/autofix/", "/api/v1/autofix/"}, + {"GET", "/api/other/*path", "/api/v1/report/r1", ""}, + {"GET", "", "/api/v1/report/r1", ""}, + } { + if got := ResolveProxyTemplate(tt.method, tt.fullPath, tt.path); got != tt.want { + t.Errorf("Resolve(%s %s)=%q want %q", tt.method, tt.path, got, tt.want) + } + } +} + +func TestProxyCatalogIsCopiedAndClearable(t *testing.T) { + routes := []Route{{"GET", "/api/v1/a"}} + withProxyCatalog(t, routes) + routes[0].Path = "/api/v1/mutated" + got := ProxyCatalog(AIOpsProxyTemplate) + got[0].Path = "/api/v1/mutated-again" + if ProxyCatalog(AIOpsProxyTemplate)[0].Path != "/api/v1/a" { + t.Fatal("catalog must be copied on set and get") + } + SetProxyCatalog(AIOpsProxyTemplate, nil) + if len(ProxyCatalog(AIOpsProxyTemplate)) != 0 || ResolveProxyTemplate("GET", AIOpsProxyTemplate, "/api/v1/a") != "" { + t.Fatal("empty catalog should clear registration") + } +} + +func TestBuildCatalogAndExpand(t *testing.T) { + withProxyCatalog(t, []Route{{"GET", "/api/v1/a"}, {"POST", "/api/v1/b"}, {"PATCH", "/api/v1/c"}}) + routes := gin.RoutesInfo{ + {Method: "GET", Path: "/api/item/:id"}, + {Method: "GET", Path: "/api/item/:id"}, // 重复 + {Method: "PATCH", Path: "/api/item/:id"}, + {Method: "POST", Path: "/api/user/signup"}, + {Method: "GET", Path: "/api/department/list"}, + {Method: "GET", Path: "/api/user/codes"}, + {Method: "GET", Path: "/api/user/permissions"}, + {Method: "GET", Path: "/health"}, + {Method: "GET", Path: AIOpsProxyTemplate}, // 代理只注册了 GET + } + var codes []string + for _, entry := range BuildCatalog(routes) { + codes = append(codes, entry.Code()+"|"+entry.Via) + } + want := "GET /api/item/:id|,GET /api/v1/*path|,GET /api/v1/a|/api/v1/*path" + if strings.Join(codes, ",") != want { + t.Fatalf("catalog=%v", codes) + } + + catalog := BuildCatalog(routes) + if got := Expand(nil, catalog); got == nil || len(got) != 0 { + t.Fatalf("empty grants must expand to [], got %#v", got) + } + if got := strings.Join(Expand([]Grant{{1, AIOpsProxyTemplate}}, catalog), ","); got != "GET /api/v1/*path,GET /api/v1/a" { + t.Fatalf("whole proxy: %s", got) + } + if got := strings.Join(Expand([]Grant{{1, "/api/v1/a"}, {1, "/api/v1/a"}, {1, "/api/item/:id"}}, catalog), ","); got != "GET /api/item/:id,GET /api/v1/a" { + t.Fatalf("exact: %s", got) + } + if got := Expand([]Grant{{2, "/*"}}, catalog); len(got) != 0 { + t.Fatalf("super-only and unregistered-method entries must not appear: %v", got) + } +} + +func TestRequiresSuperAdminAndSkips(t *testing.T) { + if !RequiresSuperAdmin("POST", "/api/user/signup") || RequiresSuperAdmin("GET", "/api/user/signup") { + t.Fatal("signup") + } + if !RequiresSuperAdmin("GET", "/api/department") || !RequiresSuperAdmin("DELETE", "/api/department/:id") || RequiresSuperAdmin("GET", "/api/departments") { + t.Fatal("department") + } + if !SkipsAuthorization("/api/user/permissions") || SkipsAuthorization("/api/user/list") { + t.Fatal("skip") + } +} diff --git a/internal/permission/proxy.go b/internal/permission/proxy.go new file mode 100644 index 00000000..dcf62c04 --- /dev/null +++ b/internal/permission/proxy.go @@ -0,0 +1,100 @@ +package permission + +import ( + "strings" + "sync" +) + +// AIOpsProxyTemplate 是 AIOps 反向代理在 gin 中注册的通配模板。 +const AIOpsProxyTemplate = "/api/v1/*path" + +// Route 是代理目录中的一个接口模板。 +type Route struct { + Method string `json:"method"` + Path string `json:"path"` +} + +var ( + proxyMu sync.RWMutex + proxyCatalogs = map[string][]Route{} +) + +// SetProxyCatalog 为某个 gin 通配模板登记下游接口目录(覆盖原有登记)。 +// 代理请求的 c.FullPath() 只有通配模板,鉴权需要借助目录还原出具体接口模板。 +func SetProxyCatalog(template string, routes []Route) { + copied := append([]Route(nil), routes...) + proxyMu.Lock() + defer proxyMu.Unlock() + if len(copied) == 0 { + delete(proxyCatalogs, template) + return + } + proxyCatalogs[template] = copied +} + +// ProxyCatalog 返回某个通配模板登记的下游接口目录副本。 +func ProxyCatalog(template string) []Route { + proxyMu.RLock() + defer proxyMu.RUnlock() + return append([]Route(nil), proxyCatalogs[template]...) +} + +// ResolveProxyTemplate 把命中代理通配模板的真实请求路径解析为目录中最具体的接口模板。 +// fullPath 不是已登记的代理模板、或目录中没有匹配项时返回 ""。 +// +// 匹配逐段进行:段数必须相同,静态段必须完全相等,:param 段只匹配一个非空且不为 +// "." / ".." 的段。多个模板同时匹配时,最先出现静态段的模板胜出(静态优先于参数, +// 与 gin 路由优先级一致);完全并列时取目录中靠前的一条。 +func ResolveProxyTemplate(method, fullPath, requestPath string) string { + proxyMu.RLock() + routes := proxyCatalogs[fullPath] + proxyMu.RUnlock() + if len(routes) == 0 { + return "" + } + requested := strings.Split(requestPath, "/") + best, bestShape := "", []bool(nil) + for _, route := range routes { + if route.Method != method { + continue + } + shape, ok := matchSegments(strings.Split(route.Path, "/"), requested) + if !ok { + continue + } + if best == "" || moreSpecific(shape, bestShape) { + best, bestShape = route.Path, shape + } + } + return best +} + +// matchSegments 返回每一段是否为静态段。 +func matchSegments(template, requested []string) ([]bool, bool) { + if len(template) != len(requested) { + return nil, false + } + shape := make([]bool, len(template)) + for i, part := range template { + if strings.HasPrefix(part, ":") && len(part) > 1 { + if requested[i] == "" || requested[i] == "." || requested[i] == ".." { + return nil, false + } + continue + } + if part != requested[i] { + return nil, false + } + shape[i] = true + } + return shape, true +} + +func moreSpecific(a, b []bool) bool { + for i := range a { + if a[i] != b[i] { + return a[i] + } + } + return false +} diff --git a/internal/system/api/user_handler.go b/internal/system/api/user_handler.go index 2c280955..03b338f8 100644 --- a/internal/system/api/user_handler.go +++ b/internal/system/api/user_handler.go @@ -42,6 +42,7 @@ import ( type UserHandler struct { service service.UserService jwt jwt2.Handler + routes func() gin.RoutesInfo } func NewUserHandler(service service.UserService, jwt jwt2.Handler) *UserHandler { @@ -52,6 +53,8 @@ func NewUserHandler(service service.UserService, jwt jwt2.Handler) *UserHandler } func (h *UserHandler) RegisterRoutes(server *gin.Engine) { + // 权限目录在请求时按当前已注册路由构建:AIOps 代理等路由在本方法之后才注册 + h.routes = server.Routes userGroup := server.Group("/api/user") { userGroup.POST("/signup", h.SignUp) @@ -60,6 +63,7 @@ func (h *UserHandler) RegisterRoutes(server *gin.Engine) { userGroup.POST("/logout", h.Logout) userGroup.GET("/profile", h.Profile) userGroup.GET("/codes", h.GetPermCode) + userGroup.GET("/permissions", h.GetPermissions) userGroup.GET("/detail/:id", h.GetUserDetail) userGroup.GET("/list", h.GetUserList) userGroup.POST("/change_password", h.ChangePassword) @@ -169,6 +173,26 @@ func (h *UserHandler) GetPermCode(ctx *gin.Context) { }) } +// GetPermissions 返回调用者本人的 v2 权限码("METHOD /模板"),契约见 docs/api/user-permissions-v2.md。 +func (h *UserHandler) GetPermissions(ctx *gin.Context) { + uc, err := userutils.ExtractClaims(ctx) + if err != nil { + base.ErrorWithMessage(ctx, err.Error()) + return + } + if uc.Uid <= 0 { + base.ErrorWithMessage(ctx, "用户信息缺失") + return + } + var routes gin.RoutesInfo + if h.routes != nil { + routes = h.routes() + } + base.HandleRequest(ctx, nil, func() (interface{}, error) { + return h.service.GetPermissions(ctx.Request.Context(), uc.Uid, permission.BuildCatalog(routes)) + }) +} + func (h *UserHandler) GetUserList(ctx *gin.Context) { var req model.GetUserListReq diff --git a/internal/system/api/user_permissions_test.go b/internal/system/api/user_permissions_test.go new file mode 100644 index 00000000..f47830eb --- /dev/null +++ b/internal/system/api/user_permissions_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + k8sapi "github.com/GoSimplicity/AI-CloudOps/internal/k8s/api" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" + "github.com/GoSimplicity/AI-CloudOps/internal/system/dao" + "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + treeapi "github.com/GoSimplicity/AI-CloudOps/internal/tree/api" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// userRolesDAO 模拟 roleDAO.GetRoles:与线上一致只返回启用中的角色。 +type userRolesDAO struct { + dao.RoleDAO + roles []*model.Role + err error +} + +func (d userRolesDAO) GetRoles(context.Context, int) ([]*model.Role, error) { + if d.err != nil { + return nil, d.err + } + var enabled []*model.Role + for _, role := range d.roles { + if role.Status == 1 { + enabled = append(enabled, role) + } + } + return enabled, nil +} + +// middlewareRoles 让鉴权中间件读取与权限接口相同的角色来源。 +type middlewareRoles struct { + service.RoleService + dao userRolesDAO +} + +func (r middlewareRoles) GetUserRoles(ctx context.Context, uid int) (*model.ListResp[*model.Role], error) { + roles, err := r.dao.GetRoles(ctx, uid) + if err != nil { + return nil, err + } + return &model.ListResp[*model.Role]{Items: roles}, nil +} + +var testAIOpsCatalog = []permission.Route{ + {Method: "GET", Path: "/api/v1/assistant/config"}, + {Method: "GET", Path: "/api/v1/assistant/session/:session_id"}, + {Method: "POST", Path: "/api/v1/assistant/query"}, + {Method: "POST", Path: "/api/v1/autofix"}, + {Method: "POST", Path: "/api/v1/autofix/"}, +} + +// permissionsRouter 按线上顺序装配:登录态 -> 鉴权 -> 真实 handler 路由(业务 handler 由终止中间件代替)。 +func permissionsRouter(t *testing.T, roles userRolesDAO, uid int) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + previous := permission.ProxyCatalog(permission.AIOpsProxyTemplate) + permission.SetProxyCatalog(permission.AIOpsProxyTemplate, testAIOpsCatalog) + t.Cleanup(func() { permission.SetProxyCatalog(permission.AIOpsProxyTemplate, previous) }) + + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: uid, Username: "operator"}); c.Next() }) + engine.Use(middleware.NewAuthMiddleware(middlewareRoles{dao: roles}).CheckAuth()) + engine.Use(func(c *gin.Context) { + if c.FullPath() != "/api/user/permissions" { + c.Header("X-Matched", c.FullPath()) + c.AbortWithStatus(204) + } + }) + NewUserHandler(service.NewUserService(nil, roles, zap.NewNop()), nil).RegisterRoutes(engine) + k8sapi.NewK8sEventHandler(nil).RegisterRouters(engine) + treeapi.NewCloudAccountHandler(nil).RegisterRouters(engine) + treeapi.NewTreeCloudHandler(nil, nil, nil, nil).RegisterRouters(engine) + NewDepartmentHandler(nil).RegisterRouters(engine) + // 与 registerAIOps 一样在其他路由之后注册代理 + engine.Any(permission.AIOpsProxyTemplate, func(c *gin.Context) { c.Status(204) }) + return engine +} + +type permissionsBody struct { + Code int `json:"code"` + Data model.UserPermissionsResp `json:"data"` + Raw map[string]json.RawMessage +} + +func fetchPermissions(t *testing.T, engine *gin.Engine) (permissionsBody, string) { + t.Helper() + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/user/permissions", nil)) + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body permissionsBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body, rec.Body.String() +} + +func TestUserPermissionsExpandsGrantsToMethodTemplates(t *testing.T) { + roles := userRolesDAO{roles: []*model.Role{ + {Status: 1, Apis: []*model.Api{ + {Method: 1, Path: "/api/k8s/clusters/:cluster_id/events/:namespace/:name"}, + {Method: 3, Path: "/api/tree/cloud/account/:id/status"}, + {Method: 1, Path: "/api/v1/assistant/*"}, + {Method: 1, Path: "/api/tree/*/list"}, // 中间通配:不再支持 + {Method: 2, Path: "/api/department/*"}, // 超管专属:授予无效 + {Method: 2, Path: "/api/user/signup"}, // 超管专属:授予无效 + {Method: 1, Path: "/api/user/codes"}, // 不经授权的接口不列出 + {Method: 1, Path: "/api/tree/cloud/9/detail"}, // 真实路径不是模板 + }}, + {Status: 0, Apis: []*model.Api{{Method: 4, Path: "/*"}}}, // 禁用角色 + }} + body, raw := fetchPermissions(t, permissionsRouter(t, roles, 5)) + want := []string{ + "GET /api/k8s/clusters/:cluster_id/events/:namespace/:name", + "GET /api/v1/assistant/config", + "GET /api/v1/assistant/session/:session_id", + "PUT /api/tree/cloud/account/:id/status", + } + if body.Code != 0 || body.Data.Version != 2 || body.Data.SuperAdmin || strings.Join(body.Data.Permissions, "\n") != strings.Join(want, "\n") { + t.Fatalf("unexpected response: %s", raw) + } +} + +func TestUserPermissionsForSuperAdminAndEmptyUser(t *testing.T) { + super := userRolesDAO{roles: []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 1, Status: 1}}} + body, raw := fetchPermissions(t, permissionsRouter(t, super, 1)) + if !body.Data.SuperAdmin || len(body.Data.Permissions) != 1 || body.Data.Permissions[0] != permission.SuperCode { + t.Fatalf("super admin: %s", raw) + } + + // 借用 super_admin 编码的普通角色不是超管 + impostor := userRolesDAO{roles: []*model.Role{{Code: permission.SuperAdminRoleCode, IsSystem: 0, Status: 1}}} + body, raw = fetchPermissions(t, permissionsRouter(t, impostor, 3)) + if body.Data.SuperAdmin || body.Data.Permissions == nil || len(body.Data.Permissions) != 0 { + t.Fatalf("impostor: %s", raw) + } + if !strings.Contains(raw, `"permissions":[]`) { + t.Fatalf("permissions must serialise as [] not null: %s", raw) + } +} + +func TestUserPermissionsReportsRoleLookupFailure(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 5}); c.Next() }) + NewUserHandler(service.NewUserService(nil, userRolesDAO{err: errors.New("db down")}, zap.NewNop()), nil).RegisterRoutes(engine) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/user/permissions", nil)) + var body permissionsBody + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body.Code == 0 || len(body.Data.Permissions) != 0 { + t.Fatalf("lookup failure must not return permissions: %s", rec.Body.String()) + } +} + +// 权限接口展开的列表必须与鉴权中间件的实际判定逐条一致。 +func TestUserPermissionsAgreeWithAuthorizationMiddleware(t *testing.T) { + for name, apis := range map[string][]*model.Api{ + "exact": {{Method: 1, Path: "/api/k8s/clusters/:cluster_id/events/:namespace/:name"}, {Method: 3, Path: "/api/tree/cloud/:id/status"}}, + "module": {{Method: 3, Path: "/api/tree/*"}, {Method: 1, Path: "/api/k8s/clusters/:cluster_id/events/*"}}, + "proxy prefix": {{Method: 2, Path: "/api/v1/autofix*"}, {Method: 1, Path: "/api/v1/assistant/session/:session_id"}}, + "whole proxy": {{Method: 2, Path: permission.AIOpsProxyTemplate}}, + "global": {{Method: 1, Path: "/*"}, {Method: 4, Path: "/*"}}, + } { + t.Run(name, func(t *testing.T) { + roles := userRolesDAO{roles: []*model.Role{{Status: 1, Apis: apis}}} + engine := permissionsRouter(t, roles, 5) + body, _ := fetchPermissions(t, engine) + granted := map[string]bool{} + for _, code := range body.Data.Permissions { + granted[code] = true + } + checked := 0 + for _, entry := range permission.BuildCatalog(engine.Routes()) { + if strings.Contains(entry.Path, "*") { + continue // gin 通配模板本身没有唯一的具体请求路径 + } + concrete := entry.Path + for _, part := range strings.Split(entry.Path, "/") { + if strings.HasPrefix(part, ":") { + concrete = strings.Replace(concrete, part, "v"+part[1:], 1) + } + } + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(permission.MethodName(entry.Method), concrete, nil)) + if matched := rec.Header().Get("X-Matched"); rec.Code == 204 && entry.Via == "" && matched != entry.Path { + t.Fatalf("%s routed to %s", concrete, matched) + } + allowed := rec.Code == 204 + if allowed != granted[entry.Code()] { + t.Errorf("%s: middleware allowed=%v, permissions listed=%v", entry.Code(), allowed, granted[entry.Code()]) + } + checked++ + } + if checked < 40 { + t.Fatalf("catalog too small: %d", checked) + } + }) + } +} diff --git a/internal/system/service/user_service.go b/internal/system/service/user_service.go index f968cc88..b0ee190d 100644 --- a/internal/system/service/user_service.go +++ b/internal/system/service/user_service.go @@ -33,6 +33,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/internal/permission" "github.com/GoSimplicity/AI-CloudOps/internal/system/dao" userutils "github.com/GoSimplicity/AI-CloudOps/internal/system/utils" "go.uber.org/zap" @@ -44,6 +45,7 @@ type UserService interface { Login(ctx context.Context, user *model.UserLoginReq) (*model.User, error) GetProfile(ctx context.Context, uid int) (*model.User, error) GetPermCode(ctx context.Context, uid int) ([]string, error) + GetPermissions(ctx context.Context, uid int, catalog []permission.CatalogEntry) (*model.UserPermissionsResp, error) GetUserDetail(ctx context.Context, uid int) (*model.User, error) GetUserList(ctx context.Context, req *model.GetUserListReq) (model.ListResp[*model.User], error) ChangePassword(ctx context.Context, req *model.ChangePasswordReq) error @@ -125,6 +127,23 @@ func (us *userService) GetPermCode(ctx context.Context, uid int) ([]string, erro return codes, nil } +// GetPermissions 把用户角色授予按与鉴权中间件相同的规则展开为 "METHOD /模板" 权限码。 +// 超管只返回超管码 "*"。 +func (us *userService) GetPermissions(ctx context.Context, uid int, catalog []permission.CatalogEntry) (*model.UserPermissionsResp, error) { + roles, err := us.roleDao.GetRoles(ctx, uid) + if err != nil { + us.logger.Error("获取用户角色失败", zap.Int("uid", uid), zap.Error(err)) + return nil, err + } + if permission.HasSuperAdminRole(roles) { + return &model.UserPermissionsResp{Version: model.UserPermissionsVersion, SuperAdmin: true, Permissions: []string{permission.SuperCode}}, nil + } + return &model.UserPermissionsResp{ + Version: model.UserPermissionsVersion, + Permissions: permission.Expand(permission.GrantsFromRoles(roles), catalog), + }, nil +} + func (us *userService) GetUserList(ctx context.Context, req *model.GetUserListReq) (model.ListResp[*model.User], error) { page := req.Page if page <= 0 { From e7cc52538d874bf948c63ff91ad6da7b8786e1ec Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:01:18 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(auth):=20SEC-24=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E9=80=9A=E9=85=8D=E5=86=99=E6=B3=95=E4=B8=8E=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=8E=88=E4=BA=88=E7=9A=84=E4=B8=8A=E7=BA=BF?= =?UTF-8?q?=E7=9B=98=E7=82=B9=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/user-permissions-v2.md | 3 +++ docs/release/rbac-super-admin.md | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/api/user-permissions-v2.md b/docs/api/user-permissions-v2.md index 77557d60..4a82a19b 100644 --- a/docs/api/user-permissions-v2.md +++ b/docs/api/user-permissions-v2.md @@ -49,8 +49,11 @@ | 普通模板,如 `/api/k8s/clusters/:cluster_id/events/:namespace/:name` | 只覆盖**完全相同**的模板 | 1 条(若该模板已注册) | | `/*` | 覆盖所有模板 | 该方法下目录内全部路由 | | 以 `*` 结尾,如 `/api/k8s/*` | 对**模板字符串**做前缀匹配(前缀为去掉末尾 `*` 的部分) | 该方法下所有以 `/api/k8s/` 开头的模板 | +| 单独的 `*` | 与改动前一致,等同 `/*`(前缀为空) | 该方法下目录内全部路由 | +| `/api/v1/*` | 末尾 `*` 前缀,同时覆盖代理模板 `/api/v1/*path` 本身 | 等同授予整个 AIOps 代理(含目录外的接口) | | gin 通配模板,如 `/api/v1/*path` | 与模板完全相同;对 AIOps 代理而言表示整个代理模块 | ` /api/v1/*path` 以及该方法下全部 AIOps 目录接口 | | 其他含 `*` 的写法(开头 `*`、中间 `*`、多个 `*`) | **不再支持**,不覆盖任何模板 | 0 条 | +| 真实路径,如 `/api/tree/cloud/9/status` | 不是模板,不覆盖任何模板(改动前会精确命中该 ID) | 0 条 | | 未注册的路径 / 已失效的授予 | 不覆盖任何模板 | 0 条 | ### 2.4 不出现在列表中的接口 diff --git a/docs/release/rbac-super-admin.md b/docs/release/rbac-super-admin.md index 783b2c9b..d82a1d0c 100644 --- a/docs/release/rbac-super-admin.md +++ b/docs/release/rbac-super-admin.md @@ -45,15 +45,21 @@ 2. 上线前用只读 SQL 盘点会受精确鉴权影响的授予(在数据库副本或只读账号执行): ```sql - -- 含 * 但不是 /* 或末尾 * 的授予:上线后不再生效 + -- 含 * 但不是“恰好一个且位于末尾”的授予:上线后不再生效 SELECT id, path, method FROM cl_system_apis WHERE deleted_at = 0 AND path LIKE '%*%' - AND path <> '/*' AND path NOT LIKE '%*' ; + AND NOT (path LIKE '%*' AND path NOT LIKE '%*%*') ; + -- 被角色引用、不含 * 也不含 : 的授予:若写的是真实路径(如 /api/tree/cloud/9/status) + -- 而不是模板,上线后不再生效;请与 gin 路由表人工核对 + SELECT DISTINCT a.id, a.path, a.method FROM cl_system_apis a + JOIN cl_system_role_apis ra ON ra.api_id = a.id + WHERE a.deleted_at = 0 AND a.path NOT LIKE '%*%' AND a.path NOT LIKE '%:%' ; -- 已存在的同名角色(引导不会提升它们) SELECT id, name, code, is_system, status FROM cl_system_roles WHERE deleted_at = 0 AND (code = 'super_admin' OR name = '超级管理员'); ``` 第一条结果里形如 `/api/v1/*path` 的 gin 通配模板仍然有效(与模板完全相等),其余需要改成末尾 `*` 或具体模板。 + 第二条结果中,启动时 `bootstrapPermissionCatalog` 自动补录的条目都是路由模板,不受影响;需要关注的是手工录入的真实路径。上线后也可以用受影响用户调用 `GET /api/user/permissions`,列表里缺失的就是不再生效的授予。 3. 部署新版本,启动日志确认无 `初始化超级管理员角色失败`。 4. 验证:以 admin 登录,`GET /api/user/permissions` 返回 `super_admin=true`、`permissions=["*"]`;以普通测试账号验证契约第 3.1 节的拒绝用例。