scope.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. // Package scope 多租户作用域注入(institution/campus 强制过滤防线,架构规格 §3.5)。
  2. //
  3. // 数据流(三段式,载体明确):
  4. // 1. Auth 中间件:验签成功后从 token claims 解析出 *Scope,调 Set(c, sc) 写入
  5. // fiber.Ctx 的请求级局部值(c.Locals);
  6. // 2. Handler:调 FromFiber(c) 读回 *Scope,连同 DB 句柄一起构造业务 Repository
  7. // (NewBaseRepo(db, sc))——**作用域不进 context、不靠隐式全局**,
  8. // 构造 Repository 即"绑定本次请求的租户",后续所有查询强制带机构/校区过滤;
  9. // 3. Repository:BaseRepo.scoping 把 institution_id / campus_id 条件拼进每条查询,
  10. // **禁止裸查**(风险登记册"跨校区越权")。
  11. //
  12. // 为什么不走 context.Value:Fiber v2 的 *fiber.Ctx 不实现标准 context.Context
  13. // (缺 Deadline/Value 透传),若把 c 当 context 传递下游取不到;显式"Handler 取 Scope
  14. // → 构造 Repo"最直白、可单测(无需 mock fiber 请求),且杜绝"忘带 context 导致作用域丢失"。
  15. //
  16. // 依赖方向铁律:本包属 infra,只被业务域依赖,不依赖任何业务域;
  17. // 作用域的"解析"(token → user/campus/role)由 identity/org 域完成,本包只负责"承载与过滤"。
  18. //
  19. // 典型使用:
  20. //
  21. // // Handler(业务域内)
  22. // func List(c *fiber.Ctx) error {
  23. // sc := scope.FromFiber(c) // Auth 中间件已写入
  24. // if sc == nil { return response.Fail(c, errors.ErrUnauth) }
  25. // repo := newCourseRepo(db, sc) // 作用域绑定到仓储
  26. // page, size := response.ParsePage(c) // 统一分页参数(默认 1/10,上限 200)
  27. // list, total, err := repo.List(c.Context(), Filter{...}, page, size)
  28. // if err != nil { return response.Fail(c, err) }
  29. // return response.Page(c, list, page, size, total)
  30. // }
  31. //
  32. // // Repository(业务域内)
  33. // type CourseRepo struct{ scope.BaseRepo }
  34. // func newCourseRepo(db *gorm.DB, sc *scope.Scope) *CourseRepo {
  35. // return &CourseRepo{scope.BaseRepo{DB: db, Sc: sc}}
  36. // }
  37. // func (r *CourseRepo) List(ctx context.Context, f Filter) ([]model.Course, int64, error) {
  38. // var list []model.Course
  39. // var total int64
  40. // q := r.scoping(r.DB.WithContext(ctx).Model(&model.Course{})) // 强制带机构/校区
  41. // if err := q.Where("status = ?", f.Status).
  42. // Count(&total). // Count 收 *int64(GORM 纪律)
  43. // Order("created_at DESC").
  44. // Find(&list).Error; err != nil {
  45. // return nil, 0, errors.WithCause(errors.ErrDBError, err)
  46. // }
  47. // return list, total, nil
  48. // }
  49. package scope
  50. import (
  51. "context"
  52. "github.com/gofiber/fiber/v2"
  53. "gorm.io/gorm"
  54. )
  55. // Scope 多租户作用域,由 Auth 中间件从 token claims 解析后注入。
  56. //
  57. // 字段:
  58. // - InstitutionID int64:当前请求所属机构 ID(必填;0 表示缺失,视为异常请求应拒绝)。
  59. // - CampusID int64:当前请求所属校区 ID(0 表示不限校区,如机构级超管视角)。
  60. // - UserID string:登录用户 ID(canonical_user_id,ULID 26 位,对齐 ADR-013/D1;
  61. // 2026-11-06 由 int64 修正为 string——新域外键一律 ULID,见 identity/model.User.CanonicalUserID)。
  62. // - Role string:角色码(7 角色之一:super_admin/academic/finance/teacher/student/parent/market;市场登录入口禁用)。
  63. // - SuperAdmin bool:是否平台超管(跨机构操作,scoping 跳过机构过滤)。
  64. type Scope struct {
  65. InstitutionID int64
  66. CampusID int64
  67. UserID string
  68. Role string
  69. SuperAdmin bool
  70. }
  71. // CtxKey 作用域在 fiber.Ctx 局部值(c.Locals)中的键。
  72. // 导出以便"中间件写入"与"Handler 读取"两端使用同一常量,避免字符串散落。
  73. const CtxKey = "scope"
  74. // Set 把作用域写入 fiber.Ctx 的请求级局部值(Auth 中间件在验签成功后调用)。
  75. //
  76. // 入参:
  77. // - c *fiber.Ctx:Fiber 请求上下文。
  78. // - sc *Scope:已解析好的作用域;字段合法性由调用方(identity 域验签逻辑)保证。
  79. //
  80. // 出参:
  81. // - *fiber.Ctx:同一个 c(便于中间件链书写:scope.Set(c, sc) 后 c.Next())。
  82. //
  83. // 异常场景:
  84. // - sc 为 nil:不写入(后续 FromFiber 返回 nil,Handler 应拒绝请求或 scoping 纵深防御拦截)。
  85. func Set(c *fiber.Ctx, sc *Scope) *fiber.Ctx {
  86. if sc != nil {
  87. _ = c.Locals(CtxKey, sc) // 写入请求级局部值(返回写入前的旧值,本处忽略)
  88. }
  89. return c
  90. }
  91. // FromFiber 从 fiber.Ctx 局部值读取作用域(Handler 侧入口,替代 context 通道)。
  92. //
  93. // 入参:
  94. // - c *fiber.Ctx:当前请求上下文。
  95. //
  96. // 出参:
  97. // - *Scope:写入的作用域;未写入或类型不符时返回 nil(调用方必须判 nil——
  98. // nil 代表"公开路由/未认证/中间件链配置错误",公开接口的 Repository 不走 BaseRepo)。
  99. //
  100. // 说明:c.Locals(key) 单参调用为"读",返回 interface{};未设置时为 nil。
  101. func FromFiber(c *fiber.Ctx) *Scope {
  102. if c == nil {
  103. return nil
  104. }
  105. if sc, ok := c.Locals(CtxKey).(*Scope); ok {
  106. return sc
  107. }
  108. return nil
  109. }
  110. // BaseRepo 所有业务域 Repository 的基类:持有数据库句柄与本次请求的作用域,
  111. // 提供 scoping 强制过滤——多租户防线的唯一收口点。
  112. //
  113. // 字段:
  114. // - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
  115. // - Sc *Scope:当前请求作用域;由 Repository 构造时从 Handler 传入(见 FromFiber 链路)。
  116. //
  117. // 使用约定:
  118. // - 业务域 Repo 通过组合 BaseRepo 获得 scoping 能力(见包注释样例);
  119. // - 所有 List/Get/Update/Delete 必须先过 scoping,禁止直接 r.DB.Where(...);
  120. // - 构造入口应显式传入 *Scope;Sc 为 nil 时 scoping 纵深防御 panic(见下)。
  121. type BaseRepo struct {
  122. DB *gorm.DB
  123. Sc *Scope
  124. }
  125. // NewBaseRepo 构造作用域仓储基类(Handler 在拿到 Scope 后调用)。
  126. //
  127. // 入参:
  128. // - db *gorm.DB:数据库句柄(必填)。
  129. // - sc *Scope:当前请求作用域(可能为 nil——系统任务/割接脚本等无用户作用域场景,
  130. // 由具体 Repository 决定是否允许;nil 进入 scoping 会 panic,属预期 fail-fast)。
  131. //
  132. // 出参:
  133. // - BaseRepo:基类实例(值类型;业务 Repo 通常内嵌 `scope.BaseRepo`)。
  134. //
  135. // 说明:不在此处拒绝 nil——部分平台级操作(超管跨机构、定时任务、割接)确实无作用域;
  136. // 是否允许"无作用域"由具体域评审决定,scoping 的 panic 是最后一道兜底防线。
  137. func NewBaseRepo(db *gorm.DB, sc *Scope) BaseRepo {
  138. return BaseRepo{DB: db, Sc: sc}
  139. }
  140. // scoping 把作用域过滤条件拼进查询(BaseRepo 的受保护入口,业务 Repo 通过组合调用)。
  141. //
  142. // 入参:
  143. // - q *gorm.DB:基础查询(通常 r.DB.WithContext(ctx).Model(&model.T{}))。
  144. //
  145. // 出参:
  146. // - *gorm.DB:追加过滤条件后的查询(原 q 不被就地修改,链式安全)。
  147. //
  148. // 过滤规则(与架构规格 §3.5 对齐):
  149. // 1. Sc 为 nil:**panic**(errNoScope 文案)——无作用域上下文进入数据访问层属
  150. // 配置错误(正常请求链路 Auth 中间件保证作用域已注入),fail-fast 优于
  151. // 静默放行裸查;panic 由 HTTP 接入层全局 Recover 兜底为 5001 + 结构化日志,
  152. // 不影响生产可用性(单测须覆盖该分支);
  153. // 2. SuperAdmin=true:不加机构/校区过滤(平台超管跨机构);
  154. // 3. 普通用户:强制 institution_id = Sc.InstitutionID;CampusID>0 时再强制 campus_id。
  155. //
  156. // 异常场景:
  157. // - 表没有 institution_id 列(如平台级字典表):该表的 Repository 不应继承 BaseRepo,
  158. // 直接使用裸 *gorm.DB(代码评审中显式标注"平台级数据")。
  159. func (r BaseRepo) scoping(q *gorm.DB) *gorm.DB {
  160. if r.Sc == nil {
  161. // 纵深防御:无作用域上下文进入数据访问层,fail-fast(全局 Recover 兜底 5001)
  162. panic(errNoScope.Error())
  163. }
  164. if r.Sc.SuperAdmin {
  165. return q // 超管跨机构,不加作用域
  166. }
  167. q = q.Where("institution_id = ?", r.Sc.InstitutionID) // 强制机构
  168. if r.Sc.CampusID > 0 {
  169. q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区
  170. }
  171. return q
  172. }
  173. // WithContext 便捷方法:把标准 context.Context 与 BaseRepo 作用域一起绑定,
  174. // 供业务 Repo 在方法内 `r.WithContext(ctx)` 起步(避免每个方法手写 r.DB.WithContext(ctx))。
  175. //
  176. // 入参:
  177. // - ctx context.Context:标准请求上下文(GORM 用其传递取消/超时信号,与 fiber 解耦)。
  178. //
  179. // 出参:
  180. // - *gorm.DB:WithContext 后的查询句柄(**未**加作用域——仍需调用方追加 r.scoping(...),
  181. // 防止"以为 WithContext 带了租户"的误用;作用域永远显式经 scoping 注入)。
  182. //
  183. // 说明:此方法只处理"context 传递",**不**处理作用域——两段式显式注入是本包的核心纪律。
  184. func (r BaseRepo) WithContext(ctx context.Context) *gorm.DB {
  185. return r.DB.WithContext(ctx)
  186. }
  187. // errNoScope 无作用域上下文的哨兵错误(scoping panic 文案,Recover 日志中可检索)。
  188. // 包级私有;不对外暴露 error 值(panic 场景由全局 Recover 统一处理为 5001)。
  189. var errNoScope = errString("scope: missing tenant scope, refusing unsoped query")
  190. // errString 实现 error 的最小字符串错误类型(避免引入 pkg/errors 依赖)。
  191. type errString string
  192. // Error 实现 error 接口。
  193. // 入参:无。出参:string——固定文案 "scope: missing tenant scope, refusing unsoped query"。
  194. func (e errString) Error() string { return string(e) }