Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions docs/api/user-permissions-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 用户权限码 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><空格><路由模板>
```

- `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`。若角色被授予了整个代理入口,还会额外出现 `<METHOD> /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/` 开头的模板 |
| 单独的 `*` | 与改动前一致,等同 `/*`(前缀为空) | 该方法下目录内全部路由 |
| `/api/v1/*` | 末尾 `*` 前缀,同时覆盖代理模板 `/api/v1/*path` 本身 | 等同授予整个 AIOps 代理(含目录外的接口) |
| gin 通配模板,如 `/api/v1/*path` | 与模板完全相同;对 AIOps 代理而言表示整个代理模块 | `<METHOD> /api/v1/*path` 以及该方法下全部 AIOps 目录接口 |
| 其他含 `*` 的写法(开头 `*`、中间 `*`、多个 `*`) | **不再支持**,不覆盖任何模板 | 0 条 |
| 真实路径,如 `/api/tree/cloud/9/status` | 不是模板,不覆盖任何模板(改动前会精确命中该 ID) | 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` 中基于 `*` / `/*` 的前端通配逻辑可以在迁移完成后删除。
89 changes: 89 additions & 0 deletions docs/release/rbac-super-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 内置超级管理员角色与精确鉴权(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 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 节的拒绝用例。

## 回滚

应用回滚到旧镜像即可,**不需要删除数据**:

- 旧版本按用户名判断 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 重新登录(权限每个请求实时读库,无需重启)。
9 changes: 8 additions & 1 deletion internal/app/aiops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
105 changes: 105 additions & 0 deletions internal/app/aiops_catalog_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
5 changes: 5 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading
Loading