// RBAC 种子生成器(AC-14)——**从路由表生成,不手写清单**。 // // ⚠ 两条已验证的结论,改这里之前先读: // // 1. casbin 路径必须用 Fiber 的 `:param`,不能用 `*`。 // keyMatch2 里 `*` 会展开成 `.*`(**跨 `/` 匹配**),`/admin/activities/*` // 会把 `POST /admin/activities/1/sign` 也放行 = **提权**。 // 反证见 TestRBACSeedEnforcedByCasbin:`teacher POST /admin/activities/9/sign` 必须为 false。 // ⚠ 既有 /org 种子用的是 `*`,是否存在同样的跨段放行**尚未核实**,已登记待 S3 核。 // // 2. 种子从路由表生成、不要手写清单。 // 本批 40 端点,第二批还有 20 个;手写必漏,症状是"接口从 404 变成 403",比 404 更难查。 // // 依据:unified/03-migration/academic-domain-design.md AC-14 行("种子幂等 + 与路由表 1:1 对齐")/ // `01-architecture/casbin-rbac-design.md` §6(casbin p 规则种子写法:`dom=*`、`keyMatch2` 路径、 // `regexMatch` 方法、`INSERT IGNORE` 幂等)/ `02-data-model/core-model-ddl.sql`(三张表的既有种子格式)。 // // --------------------------------------------------------------------------- // 为什么必须"从路由表生成" // --------------------------------------------------------------------------- // // 本任务只挂**第一批 40 个**端点,后面还有第二批(44~63)。手写种子清单在第二批时**一定会漏**, // 而症状极其隐蔽:接口从 **404** 变成 **403(1003)**——404 一眼看出"没挂", // 403 看起来像"权限配错",排查者会去查 RBAC 矩阵/角色/token,最后才发现是种子少了一行。 // // 因此三张表全部由 `Route` 派生: // // casbin_rule ← Route.Roles × Route.Path × Route.Method(**1:1** 可反查) // t_permission ← 每个端点一个 button 权限点(code 由 method+path 推导) // t_role_permission← Route.Roles × 该端点的权限点 code // // --------------------------------------------------------------------------- // 幂等(重复执行不产生重复行) // --------------------------------------------------------------------------- // // 复用既有 DDL 的 `INSERT IGNORE` 写法(MySQL),依赖三张表已有的唯一键: // // casbin_rule UNIQUE uk_policy (ptype,v0,v1,v2,v3) // t_permission PRIMARY KEY (code) // t_role_permission UNIQUE uk_role_permission (role_code,permission_code) // // SQLite(单测库)没有 `INSERT IGNORE`,改写为 `INSERT OR IGNORE`(同语义、同依赖唯一键)。 // 两种方言都**不做"先查后插"**:先查后插在并发下会插重(两次都查不到), // 而唯一键 + IGNORE 是数据库级的原子保证。 // // --------------------------------------------------------------------------- // 与既有 `/auth`、`/org` 种子的关系(不另起一套) // --------------------------------------------------------------------------- // // 写法完全同构:`ptype=p` / `v0=角色码` / `v1=*`(不区分机构)/ `v2=path` / `v3=method 正则`, // 同一张 `casbin_rule`、同一套 model.conf,不新增表、不新增列、不改 model。 // // **唯一的路径写法差异(有意,且更安全)**:既有种子把 `:id` 段写成 `*` // (如 `/org/institutions/*`),本生成器**原样保留 Fiber 的 `:param`** 写法 // (如 `/admin/activities/:id`)。原因: // 1. `keyMatch2` 原生支持 `:param`(转成 `[^/]+`),语义与 Fiber 完全一致,无需翻译; // 2. `*` 在 keyMatch2 里是 `.*`(**跨 `/` 匹配**),`/admin/activities/*` 会连带命中 // `/admin/activities/1/sign`(POST)等本不该给该角色的端点 —— 那是一条**提权**路径。 // 保留 `:param` 后规则与路由表逐字符可对齐,`git diff` 就能审出"这条策略给了谁"。 // 3. 生成器不做"路径翻译"这一步,就不会出现"翻译函数写错、某类路径全放行"的静默缺陷。 package handler import ( "context" "fmt" "sort" "strings" "gorm.io/gorm" ) // CasbinRule `casbin_rule` 的一行(ptype + v0..v3,对齐 `infra/rbac` 的 `ruleRow`)。 // // 字段语义(casbin model.conf `p = sub, dom, obj, act`): // - Ptype:策略类型(本生成器恒 `p`;`g` 段留空供将来人级授权); // - V0:`sub` = 角色码; // - V1:`dom` = 机构 ID;本批恒 `*`(不区分机构,与既有 /org 种子一致); // - V2:`obj` = 请求 path(keyMatch2,保留下 Fiber 的 `:param` 写法); // - V3:`act` = HTTP method 正则(同 path 多 method 合并为 `GET|POST`)。 type CasbinRule struct { Ptype string V0 string V1 string V2 string V3 string } // PermissionSeed `t_permission` 的一行(权限点字典;本批只生成 `button` 型)。 // // 为什么只生成 `button` 不生成 `menu`:菜单树是**UI 结构**(`parent_code`/`path`/`icon`/`sort` // 描述的是"页面怎么摆",与"后端有哪些接口"不是同一件事,路由表推不出层级与图标。 // 硬造 menu 会把菜单树写坏(`/auth/issue` 的 menuTree 直接受影响)。菜单项由前端/产品排期单独提。 type PermissionSeed struct { Code string Type string Name string ParentCode string Path string Icon string Sort int Status string } // RolePermissionSeed `t_role_permission` 的一行(角色 × 权限点矩阵)。 type RolePermissionSeed struct { RoleCode string PermissionCode string } // RBACSeed 一次生成的三张表种子(顺序稳定,便于 diff 与单测快照)。 type RBACSeed struct { // CasbinRules 接口强制策略(`casbin_rule`,第②层)。 CasbinRules []CasbinRule // Permissions 权限点字典(`t_permission`,第①层展示用)。 Permissions []PermissionSeed // RolePermissions 角色矩阵(`t_role_permission`)。 RolePermissions []RolePermissionSeed } // SeedResult 种子落库结果(幂等判据:第二次执行时 `Inserted*` 全为 0)。 type SeedResult struct { // CasbinRules 本次生成的策略行数(不是插入行数)。 CasbinRules int // Permissions 本次生成的权限点行数。 Permissions int // RolePermissions 本次生成的角色矩阵行数。 RolePermissions int // InsertedCasbinRules 本次**实际插入**的策略行数(重复行被唯一键忽略,不计入)。 InsertedCasbinRules int64 // InsertedPermissions 本次实际插入的权限点行数。 InsertedPermissions int64 // InsertedRolePermissions 本次实际插入的角色矩阵行数。 InsertedRolePermissions int64 } // BuildRBACSeed 从路由表生成三张表种子。 // // 入参:routes []Route(生产传 `Registration.Routes`,保证"挂什么就种什么")。 // 出参: // - *RBACSeed:三张表的行(顺序稳定); // - error:权限点 code 冲突(两个端点推导出同一 code)。**冲突必须报错而不是去重**: // 去重会让某个端点的权限点在矩阵里"看起来配了、实际没有",是典型的静默失效。 func BuildRBACSeed(routes []Route) (*RBACSeed, error) { seed := &RBACSeed{ CasbinRules: make([]CasbinRule, 0, len(routes)*2), Permissions: make([]PermissionSeed, 0, len(routes)), RolePermissions: make([]RolePermissionSeed, 0, len(routes)*2), } codes := make(map[string]int, len(routes)) rolePerms := make(map[string]struct{}, len(routes)*2) casbin := make(map[casbinKey]map[string]struct{}, len(routes)) order := make([]casbinKey, 0, len(routes)) for _, r := range routes { // ---- t_permission(每个端点一个 button 权限点) code := permissionCodeFor(r.Method, r.Path) if prev, ok := codes[code]; ok { return nil, fmt.Errorf( "academic seed: 端点 %d 与端点 %d 推导出同一权限点 code %q(端点路径 %s %s / %s)——"+ "请检查 permissionCodeFor 的推导规则,不要用去重掩盖", prev, r.No, code, r.Method, r.Path, r.Name) } codes[code] = r.No seed.Permissions = append(seed.Permissions, PermissionSeed{ Code: code, Type: "button", Name: r.Name, ParentCode: "", Path: "", Icon: "", Sort: r.No, Status: "active", }) // ---- t_role_permission(角色 × 本端点权限点) for _, role := range r.Roles { key := role + "\x00" + code if _, dup := rolePerms[key]; dup { continue } rolePerms[key] = struct{}{} seed.RolePermissions = append(seed.RolePermissions, RolePermissionSeed{ RoleCode: role, PermissionCode: code, }) } // ---- casbin_rule(角色 × path,多 method 合并) for _, role := range r.Roles { key := casbinKey{role: role, path: r.Path} if _, ok := casbin[key]; !ok { casbin[key] = make(map[string]struct{}, 2) order = append(order, key) } casbin[key][r.Method] = struct{}{} } } // 按"首次出现顺序"输出(顺序稳定:注册顺序即契约端点顺序),method 按固定序合并。 for _, key := range order { methods := make([]string, 0, len(casbin[key])) for m := range casbin[key] { methods = append(methods, m) } sort.Slice(methods, func(i, j int) bool { return methodRank(methods[i]) < methodRank(methods[j]) }) seed.CasbinRules = append(seed.CasbinRules, CasbinRule{ Ptype: "p", V0: key.role, V1: "*", V2: key.path, V3: strings.Join(methods, "|"), }) } return seed, nil } // casbinKey casbin 规则的去重键(角色 + path;method 合并到 v3)。 type casbinKey struct { role string path string } // methodRank method 的稳定排序序(让 `GET|POST` 这样的正则可复现,避免 map 遍历顺序抖动)。 func methodRank(method string) int { switch method { case "GET": return 0 case "POST": return 1 case "PUT": return 2 case "PATCH": return 3 case "DELETE": return 4 default: return 9 } } // permissionCodeFor 从 `method + path` 推导三段式权限点码 `academic.<资源>.<动作>`。 // // 推导规则(**纯函数、无表驱动**:第二批端点接进来时零改动): // // 资源 = 路径去掉一级段(`/admin`|`/student`)与参数段后,字面段用 `_` 连接; // 若"路径尾是 `/:param/<动词>`",该动词**不计入资源**(它是动作)。 // 动作 = GET 且路径含参数段 → `get`;GET 无参数段 → `list`; // 非 GET 且尾是 `/:param/<动词>` → 该动词(连字符转下划线); // 其余 → POST `create` / PUT `update` / DELETE `delete`。 // // 样例(可对照交付报告的 40 行注册表逐条核): // // GET /admin/finance/accounts/:userId → academic.finance_accounts.get // POST /admin/finance/refunds/custom → academic.finance_refunds_custom.create // POST /admin/finance/requests/:id/approve → academic.finance_requests.approve // GET /admin/activities/discounts/usable → academic.activities_discounts_usable.list // POST /admin/activities/:id/cancel-sign → academic.activities.cancel_sign // GET /student/bill → academic.bill.list // PUT /admin/divide-into/members/:userId/quota → academic.divide_into_members.quota // // ⚠ **本规则是本任务推导出来的,契约里没有 academic 的权限点码词表**(现有字典只有 // `org.*` / `role.*`)。若产品/前端已有约定词表,只改本函数一处即可全量重算—— // 这正是"从路由表生成"的价值:不存在"改了 40 行漏了 3 行"。 func permissionCodeFor(method, path string) string { segs := pathSegments(path) // 去一级段(/admin 或 /student;将来 /pay 同理)。 if len(segs) > 0 { segs = segs[1:] } if len(segs) == 0 { // 理论上不会出现(本批所有端点都至少两段),兜底成一个稳定的 code。 return "academic.root." + strings.ToLower(method) } hasParam := false for _, seg := range segs { if isParamSegment(seg) { hasParam = true break } } // 尾形态 `/:param/<动词>`:最后两段为"参数 + 字面段"。 tailVerb := "" if n := len(segs); n >= 2 && isParamSegment(segs[n-2]) && !isParamSegment(segs[n-1]) { tailVerb = normalizeSegment(segs[n-1]) } literals := make([]string, 0, len(segs)) for _, seg := range segs { if isParamSegment(seg) { continue } literals = append(literals, normalizeSegment(seg)) } action := "" switch method { case "GET": if hasParam { action = "get" } else { action = "list" } default: if tailVerb != "" { action = tailVerb // 尾动词是动作,不进资源段(否则会出现 `..._approve.approve`)。 literals = literals[:len(literals)-1] } else { switch method { case "POST": action = "create" case "PUT": action = "update" case "DELETE": action = "delete" default: action = strings.ToLower(method) } } } return "academic." + strings.Join(literals, "_") + "." + action } // pathSegments 切分路径为段(丢弃前导空段)。 func pathSegments(path string) []string { trimmed := strings.Trim(path, "/") if trimmed == "" { return nil } return strings.Split(trimmed, "/") } // isParamSegment 判断是否参数段(Fiber 的 `:name` 与可选 `:name?` 均算)。 func isParamSegment(seg string) bool { return strings.HasPrefix(seg, ":") } // normalizeSegment 归一化字面段:连字符转下划线(`refund-by-activity` → `refund_by_activity`)。 func normalizeSegment(seg string) string { return strings.ReplaceAll(seg, "-", "_") } // SQL 渲染:与既有 `core-model-ddl.sql` 的种子写法逐字对齐(`INSERT IGNORE INTO ... VALUES`)。 // CasbinRulesSQL 渲染 `casbin_rule` 的 INSERT 语句。 // // 入参:dialect `mysql` / `sqlite`(决定 `INSERT IGNORE` 还是 `INSERT OR IGNORE`)。 // 出参:单条多值 INSERT(无行时返回空串)。 func (s *RBACSeed) CasbinRulesSQL(dialect string) (string, error) { if len(s.CasbinRules) == 0 { return "", nil } prefix, err := insertIgnorePrefix("casbin_rule", "`ptype`, `v0`, `v1`, `v2`, `v3`", dialect) if err != nil { return "", err } rows := make([]string, 0, len(s.CasbinRules)) for _, r := range s.CasbinRules { rows = append(rows, fmt.Sprintf("(%s)", strings.Join([]string{ sqlString(r.Ptype), sqlString(r.V0), sqlString(r.V1), sqlString(r.V2), sqlString(r.V3), }, ", "))) } return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil } // PermissionsSQL 渲染 `t_permission` 的 INSERT 语句。 func (s *RBACSeed) PermissionsSQL(dialect string) (string, error) { if len(s.Permissions) == 0 { return "", nil } prefix, err := insertIgnorePrefix("t_permission", "`code`, `type`, `name`, `parent_code`, `path`, `icon`, `sort`, `status`", dialect) if err != nil { return "", err } rows := make([]string, 0, len(s.Permissions)) for _, p := range s.Permissions { parent := "NULL" if p.ParentCode != "" { parent = sqlString(p.ParentCode) } path := "NULL" if p.Path != "" { path = sqlString(p.Path) } icon := "NULL" if p.Icon != "" { icon = sqlString(p.Icon) } rows = append(rows, fmt.Sprintf("(%s)", strings.Join([]string{ sqlString(p.Code), sqlString(p.Type), sqlString(p.Name), parent, path, icon, fmt.Sprintf("%d", p.Sort), sqlString(p.Status), }, ", "))) } return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil } // RolePermissionsSQL 渲染 `t_role_permission` 的 INSERT 语句。 func (s *RBACSeed) RolePermissionsSQL(dialect string) (string, error) { if len(s.RolePermissions) == 0 { return "", nil } prefix, err := insertIgnorePrefix("t_role_permission", "`role_code`, `permission_code`", dialect) if err != nil { return "", err } rows := make([]string, 0, len(s.RolePermissions)) for _, rp := range s.RolePermissions { rows = append(rows, fmt.Sprintf("(%s, %s)", sqlString(rp.RoleCode), sqlString(rp.PermissionCode))) } return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil } // SQL 渲染三张表的完整种子脚本(可直接追加进 `core-model-ddl.sql`,逐行可 diff)。 func (s *RBACSeed) SQL(dialect string) (string, error) { casbin, err := s.CasbinRulesSQL(dialect) if err != nil { return "", err } perms, err := s.PermissionsSQL(dialect) if err != nil { return "", err } roles, err := s.RolePermissionsSQL(dialect) if err != nil { return "", err } parts := []string{ "-- academic 域 RBAC 种子(AC-14,**由路由表生成,勿手改**:改路由后重新生成)", "-- casbin_rule:第②层接口强制(角色 × path × method;dom=* 不区分机构;path 保留 Fiber 的 :param)", casbin, "-- t_permission:button 型权限点(菜单树是 UI 结构,不由路由表推导)", perms, "-- t_role_permission:角色 × 权限点矩阵", roles, } out := make([]string, 0, len(parts)) for _, p := range parts { if strings.TrimSpace(p) == "" { continue } out = append(out, p) } return strings.Join(out, "\n") + "\n", nil } // insertIgnorePrefix 生成各方言的 `INSERT IGNORE` 前缀。 func insertIgnorePrefix(table, columns, dialect string) (string, error) { switch dialect { case "mysql": return "INSERT IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil case "sqlite": return "INSERT OR IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil default: return "", fmt.Errorf("academic seed: 不支持的方言 %q(仅 mysql/sqlite)", dialect) } } // sqlString 把字符串渲染为 SQL 字面量(单引号转义;本生成器的输入来自路由表,仍做转义以防将来注错)。 func sqlString(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } // SeedRBAC 生成并落库 RBAC 种子(**幂等**:重复执行由唯一键 + IGNORE 吸收)。 // // 入参: // - ctx context.Context:执行上下文; // - db *gorm.DB:数据库句柄(方言决定 IGNORE 关键字;仅支持 mysql/sqlite); // - routes []Route:路由表(生产传 `Registration.Routes`——**挂什么就种什么**)。 // // 出参: // - *SeedResult:生成行数 + 实际插入行数(`Inserted*` 全 0 即"已是最新",可直接用于幂等断言); // - error:权限点 code 冲突 / 方言不支持 / 执行失败。 // // 装配位置:`cmd/api` 装配层在 `UNIFIED_AUTO_MIGRATE=1`(dev 建表守卫)时调用; // 生产按"DDL 先行"纪律用本函数渲染的 SQL(见 `SQL`)随迁移脚本执行,不在启动时写权限表。 func SeedRBAC(ctx context.Context, db *gorm.DB, routes []Route) (*SeedResult, error) { if db == nil { return nil, fmt.Errorf("academic seed: DB 未注入") } seed, err := BuildRBACSeed(routes) if err != nil { return nil, err } dialect := db.Dialector.Name() res := &SeedResult{ CasbinRules: len(seed.CasbinRules), Permissions: len(seed.Permissions), RolePermissions: len(seed.RolePermissions), } if sqlText, serr := seed.CasbinRulesSQL(dialect); serr != nil { return nil, serr } else if sqlText != "" { tx := db.WithContext(ctx).Exec(sqlText) if tx.Error != nil { return nil, fmt.Errorf("academic seed: 写 casbin_rule 失败: %w", tx.Error) } res.InsertedCasbinRules = tx.RowsAffected } if sqlText, serr := seed.PermissionsSQL(dialect); serr != nil { return nil, serr } else if sqlText != "" { tx := db.WithContext(ctx).Exec(sqlText) if tx.Error != nil { return nil, fmt.Errorf("academic seed: 写 t_permission 失败: %w", tx.Error) } res.InsertedPermissions = tx.RowsAffected } if sqlText, serr := seed.RolePermissionsSQL(dialect); serr != nil { return nil, serr } else if sqlText != "" { tx := db.WithContext(ctx).Exec(sqlText) if tx.Error != nil { return nil, fmt.Errorf("academic seed: 写 t_role_permission 失败: %w", tx.Error) } res.InsertedRolePermissions = tx.RowsAffected } return res, nil }