Sfoglia il codice sorgente

feat(identity+org): T6.5-d issue/me 下发 menuTree/permissions + 路由角色白名单

- identity/service:MenuProvider 接口(依赖倒置,复用 rbac.MenuNode 类型)+
  可选注入;Issue/Me/Refresh 经 resolveMenu 下发 menuTree + permissions
  (nil 兜底空集,不阻断登录;3 项单测:注入/超管全量/nil 空集)
- identity/handler/rbac.go:RequireRole(roles...) 路由角色白名单中间件
  (超管 SuperAdmin 恒放行 / 角色命中放行 / 不符 1003 / 无 scope 1001)
- org/handler e2e:seedRBAC 最小集权限点+矩阵 + 路由挂 RequireRole
  (academic/finance/teacher)+ 2 项 DB-gated e2e(角色不符 1003 +
  真 DB menuTree 解析)
- main:rbac.Resolver 注入 MenuProvider + 路由挂 RequireRole + 编译期断言

build/vet/test 全绿(DB-gated e2e 未设 DSN 时 skip)。
Q 4 giorni fa
parent
commit
be1630c3eb

+ 24 - 9
cmd/api/main.go

@@ -48,9 +48,15 @@ import (
 	"yuansiwei/unified-backend/internal/infra/redis"
 	orghandler "yuansiwei/unified-backend/internal/org/handler"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
+	orgrepo "yuansiwei/unified-backend/internal/org/repository"
 	orgservice "yuansiwei/unified-backend/internal/org/service"
 )
 
+// 编译期断言:*rbac.Resolver 须满足 identity 的 MenuProvider 接口(签名漂移即编译失败,
+// 杜绝"装配注入了但方法签名不匹配"的隐蔽错误——生产注入点见 main() 内 service.Config.MenuProvider)。
+var _ service.MenuProvider = (*rbac.Resolver)(nil)
+
 // main 进程入口:装配 → 监听 → 信号驱动优雅退出。
 //
 // 入参(命令行 flag):
@@ -146,16 +152,21 @@ func main() {
 		logger.Warn("Auth.PublicKeysPEM 未配置,已从 PrivateKeyPEM 派生公钥(生产建议显式配置以支持密钥轮换)")
 	}
 
+	// RBAC 解析器(T6.5:role→菜单树/权限码;平台级 PermissionRepo 直连 gdb,TTL 取 0 走缺省 30s)
+	// 作为 identity 的 MenuProvider 注入:/auth/issue 与 /auth/me 据此下发 menuTree + permissions。
+	rbacResolver := rbac.NewResolver(orgrepo.NewPermissionRepo(gdb), 0)
+
 	authSvc, err := service.NewAuthService(service.Config{
-		Users:       repository.NewUserRepo(gdb),
-		Identities:  repository.NewIdentityRepo(gdb),
-		Memberships: repository.NewMembershipRepo(gdb),
-		Issuer:      issuer,
-		Tickets:     tickets,
-		AccessTTL:   accessTTL,
-		PublicKeys:  pubKeys,
-		IssuerStr:   cfg.Auth.Issuer,
-		Revocation:  revocation,
+		Users:        repository.NewUserRepo(gdb),
+		Identities:   repository.NewIdentityRepo(gdb),
+		Memberships:  repository.NewMembershipRepo(gdb),
+		Issuer:       issuer,
+		Tickets:      tickets,
+		AccessTTL:    accessTTL,
+		PublicKeys:   pubKeys,
+		IssuerStr:    cfg.Auth.Issuer,
+		Revocation:   revocation,
+		MenuProvider: rbacResolver,
 	})
 	if err != nil {
 		logger.Error("AuthService 装配失败", zap.Error(err))
@@ -221,8 +232,12 @@ func main() {
 
 	// org 域(机构/校区只读 + 作用域越权拦截,经同一验签中间件;作用域由中间件注入,
 	// Repository Scoping 强制机构/校区过滤——跨校区越权防线,架构规格 §3.5)
+	// 路由角色白名单(T6.5-d 接口强制层):机构/校区目录对 staff 角色开放
+	// (academic 教务 / finance 财务 / teacher 老师),业务级 student/parent 拒绝(1003),
+	// 超管经 SuperAdmin 恒放行。与 e2e TestOrgE2E_RouteRoleWhitelist 白名单一致。
 	org := app.Group("/org")
 	org.Use(authMw)
+	org.Use(handler.RequireRole("academic", "finance", "teacher"))
 	org.Get("/institutions", orgH.Institutions)
 	org.Get("/institutions/:institutionId", orgH.Institution)
 	org.Get("/campuses", orgH.Campuses)

+ 73 - 0
internal/identity/handler/rbac.go

@@ -0,0 +1,73 @@
+// 路由角色白名单中间件(T6.5-d,RBAC 三层模型的"接口强制层",2026-11-06 用户拍板)。
+//
+// 定位(RBAC 三层模型第②层,与展示层/数据层分工):
+//   - 展示层(org/rbac.Resolver):role→菜单树/权限码,供 /auth/issue·/auth/me 下发;
+//   - **本中间件(接口强制层)**:受保护路由声明允许访问的**角色白名单**,请求角色不在
+//     白名单内 → code 1003(无权限);超管(super_admin)代码层全量放行,不查白名单;
+//   - 数据层(org/service.assertInScope + Repository Scoping):随业务域,本中间件不涉。
+//
+// 为什么是"角色白名单"而非"权限点判定"(拍板 #1 依据):
+//   - 粒度=7 角色(super_admin/academic/finance/teacher/student/parent/market),与
+//     membership.role 对齐,路由声明直观(如 GET /org/institutions 只允许 academic+super_admin);
+//   - 权限点(域.资源.动作)走展示层下发 + 前端 v-perm,接口强制用"角色"已足够——
+//     避免每次请求查 t_role_permission 矩阵(TTL 缓存只覆盖菜单,路由白名单是静态声明);
+//   - 白名单在路由注册处**声明式**挂上(org.Use(authMw, handler.RequireRole("academic"))),
+//     改权限=改路由声明(随域模块演进),不动中间件代码。
+//
+// 数据流(与 T4-5 验签中间件衔接,须在其**之后**挂上):
+//  1. authMw 验签 + scope.Set(c, sc)(scope.Scope 已写入 c.Locals);
+//  2. 本中间件:scope.FromFiber(c) 取 *Scope;
+//  3. sc==nil → 1001(中间件链配置错误:白名单挂在公开路由上);
+//  4. sc.SuperAdmin → 放行(超管代码层全量,不查白名单);
+//  5. sc.Role ∈ 白名单 → c.Next();否则 1003(无权限,不泄漏"哪些角色可访问")。
+//
+// 错误码:
+//   - 1001(ErrUnauth):无已验签 scope(白名单误挂公开路由 / 验签中间件未先执行);
+//   - 1003(ErrForbidden):角色不在白名单(已登录但无该接口权限)。
+package handler
+
+import (
+	"github.com/gofiber/fiber/v2"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// RequireRole 构造"路由角色白名单"中间件(受保护路由专用,须挂在验签中间件之后)。
+//
+// 入参:roles []string:允许访问该路由的角色码白名单(≥1 个;空切片视为配置错误——
+// 见 fail-fast:返回固定 1003 不放行,杜绝"空白名单=全放行"的灾难性误配)。
+// 出参:fiber.Handler——可直接 app.Group(...).Use(authMw, RequireRole("academic","finance"))。
+//
+// 语义:
+//   - 超管(sc.SuperAdmin=true)恒放行(代码层全量,拍板:超管不受白名单约束);
+//   - 普通角色:sc.Role 命中白名单任一角色才放行,否则 1003;
+//   - sc==nil:1001(白名单误挂公开路由,纵深防御 fail-closed,绝不静默放行)。
+//
+// 使用样例(main.go 路由挂载):
+//
+//	org := app.Group("/org")
+//	org.Use(authMw)                                          // 先验签+scope 注入
+//	org.Use(handler.RequireRole("academic", "finance"))        // 再角色白名单
+//	org.Get("/institutions", orgH.Institutions)
+func RequireRole(roles ...string) fiber.Handler {
+	// 空白名单 fail-closed:拒绝所有(含超管除外,超管恒放行)。
+	// 配置"空白名单"通常是想放行全部角色——但正确做法是显式列全 7 角色或不挂本中间件;
+	// 静默放行会让"忘了填白名单"的开发者以为"没配=不限制",实际限制的是空集,语义反直觉。
+	return func(c *fiber.Ctx) error {
+		sc := scope.FromFiber(c)
+		if sc == nil {
+			return response.Fail(c, apperrors.ErrUnauth) // 1001:无 scope(白名单误挂公开路由)
+		}
+		if sc.SuperAdmin {
+			return c.Next() // 超管代码层全量放行(不查白名单)
+		}
+		for _, r := range roles {
+			if r == sc.Role {
+				return c.Next()
+			}
+		}
+		return response.Fail(c, apperrors.ErrForbidden) // 1003:角色不在白名单
+	}
+}

+ 120 - 39
internal/identity/service/auth_service.go

@@ -7,11 +7,12 @@
 //   - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。
 //
 // 两步登录协议(T4 契约,2026-11-06 拍板):
-//   ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
-//     memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
-//   ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
-//     (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
-//   移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
+//
+//	① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
+//	  memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
+//	② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
+//	  (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
+//	移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
 //
 // 安全要点:
 //   - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
@@ -41,6 +42,7 @@ import (
 	apperrors "yuansiwei/unified-backend/internal/infra/errors"
 	appredis "yuansiwei/unified-backend/internal/infra/redis"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
 )
 
 // errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
@@ -73,6 +75,9 @@ type AuthService struct {
 	pubKeys    []string
 	issuerStr  string
 	revocation token.RevocationStore
+	// menuProvider 角色→菜单树/权限码 解析器(T6.5-d;可空——未注入时 menuTree/permissions 恒空集,
+	// 菜单全隐/按钮全隐,不阻断登录。生产装配注入 *rbac.Resolver)。
+	menuProvider MenuProvider
 
 	// dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
 	// 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
@@ -92,6 +97,9 @@ type Config struct {
 	Revocation  token.RevocationStore
 	// LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
 	LoginTicketTTL time.Duration
+	// MenuProvider 可选:角色→菜单树/权限码 解析器(T6.5-d,注入 *rbac.Resolver);
+	// nil 时 menuTree/permissions 恒空集(菜单全隐,不阻断登录——RBAC 未装配时的兜底语义)。
+	MenuProvider MenuProvider
 }
 
 // NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
@@ -122,29 +130,57 @@ func NewAuthService(cfg Config) (*AuthService, error) {
 	// 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。
 	dummy, _ := credential.HashArgon2("timing-equalization-dummy")
 	return &AuthService{
-		users:       cfg.Users,
-		identities:  cfg.Identities,
-		memberships: cfg.Memberships,
-		issuer:      cfg.Issuer,
-		tickets:     cfg.Tickets,
-		accessTTL:   cfg.AccessTTL,
-		ticketTTL:   ttl,
-		pubKeys:     cfg.PublicKeys,
-		issuerStr:   cfg.IssuerStr,
-		revocation:  cfg.Revocation,
-		dummyHash:   dummy,
+		users:        cfg.Users,
+		identities:   cfg.Identities,
+		memberships:  cfg.Memberships,
+		issuer:       cfg.Issuer,
+		tickets:      cfg.Tickets,
+		accessTTL:    cfg.AccessTTL,
+		ticketTTL:    ttl,
+		pubKeys:      cfg.PublicKeys,
+		issuerStr:    cfg.IssuerStr,
+		revocation:   cfg.Revocation,
+		menuProvider: cfg.MenuProvider,
+		dummyHash:    dummy,
 	}, nil
 }
 
+// resolveMenu 按角色解析菜单树 + 权限码(T6.5-d;MenuProvider 未注入时返回空集,不阻断登录)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - roleCode 选定作用域的角色码(token 选定 membership 的 role;通常单条)。
+//
+// 出参:
+//   - []MenuNode:可见菜单树(**非 nil**,解析失败/未注入时返回 []);
+//   - []string:全量权限点 code 集(**非 nil**,解析失败/未注入时返回 [])。
+//
+// 语义:菜单/权限是"展示层"(拍板),DB 异常不应阻断已签发的 token 下发——
+// 返回空集 + 前端菜单全隐,用户仍可登录(数据层权限由路由白名单/Scoping 兜底)。
+func (s *AuthService) resolveMenu(ctx context.Context, roleCode string) (menu []MenuNode, perms []string) {
+	menu = []MenuNode{}
+	perms = []string{}
+	if s.menuProvider == nil {
+		return menu, perms
+	}
+	if m, err := s.menuProvider.MenuTree(ctx, roleCode); err == nil && m != nil {
+		menu = m
+	}
+	if p, err := s.menuProvider.Permissions(ctx, roleCode); err == nil && p != nil {
+		perms = p
+	}
+	return menu, perms
+}
+
 // ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R<T>)----
 
 // UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。
 type UserView struct {
-	ID         string `json:"id"`          // canonical_user_id(ULID)
-	Username   string `json:"username"`    // 用户名(可空)
-	RealName   string `json:"realName"`    // 真实姓名(可空)
-	PersonType string `json:"personType"`  // 业务身份:student/teacher/parent/staff
-	Status     string `json:"status"`      // active/disabled/merged
+	ID         string `json:"id"`         // canonical_user_id(ULID)
+	Username   string `json:"username"`   // 用户名(可空)
+	RealName   string `json:"realName"`   // 真实姓名(可空)
+	PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff
+	Status     string `json:"status"`     // active/disabled/merged
 }
 
 // MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
@@ -152,7 +188,7 @@ type MembershipView struct {
 	MembershipID  int64  `json:"membershipId"`
 	InstitutionID int64  `json:"institutionId"`
 	CampusID      int64  `json:"campusId"`
-	Role          string `json:"role"` // 字符串角色码(super_admin/academic/...)
+	Role          string `json:"role"`   // 字符串角色码(super_admin/academic/...)
 	Status        string `json:"status"` // active/inactive
 }
 
@@ -163,7 +199,14 @@ type LoginStep1 struct {
 	Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
 }
 
-// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户)。
+// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码,T6.5-d 起)。
+//
+// MenuTree / Permissions 由注入的 MenuProvider(rbac.Resolver)按选定角色解析:
+//   - MenuTree:可见菜单树(PC 前端原样渲染,不二次过滤;契约见 rbac.MenuNode);
+//   - Permissions:全量权限点 code 集(含 button,前端 v-perm 依据);
+//
+// 两者**非 nil**(无可见菜单/权限 = [],JSON 序列化为 [] 而非 null);
+// 未注入 MenuProvider 时恒为空集(菜单全隐、按钮全隐,不阻断登录——RBAC 子系统装配失败的兜底)。
 type TokenPair struct {
 	AccessToken   string     `json:"accessToken"`
 	RefreshToken  string     `json:"refreshToken"`
@@ -172,16 +215,38 @@ type TokenPair struct {
 	Roles         []string   `json:"roles"`         // 选定作用域的角色码(通常单条)
 	InstitutionID int64      `json:"institutionId"` // 选定作用域
 	CampusID      int64      `json:"campusId"`
-	Permissions   []string   `json:"permissions"` // 占位:细粒度权限码(RBAC,T6 域补;当前空)
+	MenuTree      []MenuNode `json:"menuTree"`    // 可见菜单树(按选定角色过滤后;PC 原样渲染)
+	Permissions   []string   `json:"permissions"` // 全量权限点 code 集(含 button,v-perm 依据)
+}
+
+// MenuNode 菜单树节点,直接复用 rbac.MenuNode(PC 前端契约的权威定义在 org/rbac 包)。
+// 不在此包重定义:*rbac.Resolver.MenuTree 返回 []rbac.MenuNode,复用同一类型使
+// MenuProvider 接口与生产实现签名严格一致(否则需装配层做类型转换,徒增漂移风险)。
+// 依赖方向说明:identity/service 已 import org/model(登录引用 membership),再 import
+// org/rbac(纯解析器,无 DB/handler 依赖)不破坏分层——rbac 是更下层的展示逻辑。
+type MenuNode = rbac.MenuNode
+
+// MenuProvider 角色 → 权限点 code 集 / 菜单树 解析器(T6.5-d 引入的依赖倒置接口)。
+//
+// 生产实现:*rbac.Resolver(org/rbac 包,TTL 30s 进程内缓存;超管全量、普通查 t_role_permission)。
+// 接口定义在消费方 identity/service(消费方定义接口,供装配层注入 *rbac.Resolver / 单测注入 fake);
+// rbac.Resolver 的方法签名与本接口一致,故 *rbac.Resolver 隐式满足(编译期断言在 main.go)。
+type MenuProvider interface {
+	// Permissions 角色 code 集(超管返回全量;普通角色查矩阵;空集 = 该角色无授权)。
+	Permissions(ctx context.Context, roleCode string) ([]string, error)
+	// MenuTree 角色可见菜单树(button 不进树;父缺失提升顶级;非 nil 切片)。
+	MenuTree(ctx context.Context, roleCode string) ([]rbac.MenuNode, error)
 }
 
-// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用)。
+// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用;T6.5-d 起含 MenuTree/Permissions)。
 type MeResult struct {
 	*UserView
 	Roles         []string         `json:"roles"`
 	InstitutionID int64            `json:"institutionId"` // 当前作用域(来自已验签 claims)
 	CampusID      int64            `json:"campusId"`
 	Memberships   []MembershipView `json:"memberships"`
+	MenuTree      []MenuNode       `json:"menuTree"`    // 可见菜单树(同 TokenPair;菜单刷新恢复)
+	Permissions   []string         `json:"permissions"` // 全量权限点 code 集
 }
 
 // ---- 两步登录 ----
@@ -198,13 +263,14 @@ type MeResult struct {
 // 出参:
 //   - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
 //   - error(*AppError):
-//     - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
-//     - ErrBadParam(2001):phone 非 11 位(入参格式非法);
-//     - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
+//   - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
+//   - ErrBadParam(2001):phone 非 11 位(入参格式非法);
+//   - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
 //
 // 时序纪律(防枚举 timing floor):
-//   "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
-//   "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
+//
+//	"账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
+//	"存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
 func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) {
 	ident := s.normalize(identityType, identifier)
 	if identityType == model.IdentityTypePhone && !isValidPhone(ident) {
@@ -306,9 +372,9 @@ func (s *AuthService) Login(ctx context.Context, identityType, identifier, passw
 // 出参:
 //   - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
 //   - error(*AppError):
-//     - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
-//     - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
-//     - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
+//   - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
+//   - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
+//   - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
 //
 // 安全要点:
 //   - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
@@ -358,6 +424,7 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 	if err != nil {
 		return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
 	}
+	menu, perms := s.resolveMenu(ctx, m.Role) // T6.5-d:按选定角色下发菜单树 + 权限码(失败→空集,不阻断)
 	return &TokenPair{
 		AccessToken:   access,
 		RefreshToken:  refresh,
@@ -366,7 +433,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 		Roles:         []string{m.Role},
 		InstitutionID: institutionID,
 		CampusID:      campusID,
-		Permissions:   []string{}, // RBAC 占位(T6 域补)
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 
@@ -379,8 +447,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 // 出参:
 //   - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
 //   - error(*AppError):
-//     - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
-//     - ErrDBError(5002):取用户/成员关系 DB 故障。
+//   - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
+//   - ErrDBError(5002):取用户/成员关系 DB 故障。
 func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) {
 	u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub)
 	if err != nil {
@@ -390,12 +458,19 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
 	if err != nil {
 		return nil, err
 	}
+	roleCode := ""
+	if len(claims.Roles) > 0 {
+		roleCode = claims.Roles[0] // 主角色(签发侧保证多角色时主角色在前)
+	}
+	menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:菜单刷新恢复(同 Issue)
 	return &MeResult{
 		UserView:      toUserView(u),
 		Roles:         claims.Roles,
 		InstitutionID: claims.InstitutionID,
 		CampusID:      claims.CampusID,
 		Memberships:   toMembershipViews(mergs),
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 
@@ -408,8 +483,8 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
 // 出参:
 //   - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
 //   - error(*AppError):
-//     - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
-//     - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
+//   - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
+//   - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
 //
 // 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
 // (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
@@ -431,6 +506,11 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
 	if uerr != nil {
 		return nil, uerr // ErrNotFound / ErrDBError
 	}
+	roleCode := ""
+	if len(claims.Roles) > 0 {
+		roleCode = claims.Roles[0]
+	}
+	menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:轮换后同补菜单/权限(claims 原样恢复角色)
 	return &TokenPair{
 		AccessToken:   newAccess,
 		RefreshToken:  newRefresh,
@@ -439,7 +519,8 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
 		Roles:         claims.Roles,
 		InstitutionID: claims.InstitutionID,
 		CampusID:      claims.CampusID,
-		Permissions:   []string{},
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 

+ 119 - 1
internal/identity/service/auth_service_test.go

@@ -12,8 +12,8 @@ package service
 import (
 	"context"
 	"crypto/md5"
-	stderrors "errors"
 	"encoding/hex"
+	stderrors "errors"
 	"testing"
 
 	"yuansiwei/unified-backend/internal/identity/credential"
@@ -21,6 +21,7 @@ import (
 	"yuansiwei/unified-backend/internal/identity/token"
 	apperrors "yuansiwei/unified-backend/internal/infra/errors"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
 )
 
 // codeOf 取业务错误码(*AppError.Code);非 *AppError 或 nil 时 Fatal(测试 bug)。
@@ -325,6 +326,123 @@ func TestMe_UnknownSub(t *testing.T) {
 	}
 }
 
+// ---- T6.5-d:MenuProvider 注入后 Issue/Me 下发 menuTree + permissions ----
+
+// fakeMenuProvider MenuProvider 测试假实现(按角色返回固定菜单树/权限码集;不连 DB)。
+type fakeMenuProvider struct {
+	codes map[string][]string
+	tree  map[string][]rbac.MenuNode
+}
+
+func (f *fakeMenuProvider) Permissions(_ context.Context, role string) ([]string, error) {
+	if c, ok := f.codes[role]; ok {
+		return c, nil
+	}
+	return []string{}, nil
+}
+func (f *fakeMenuProvider) MenuTree(_ context.Context, role string) ([]rbac.MenuNode, error) {
+	if tr, ok := f.tree[role]; ok {
+		return tr, nil
+	}
+	return []rbac.MenuNode{}, nil
+}
+
+// TestIssue_WithMenuProvider 注入 MenuProvider 后 Issue 出参含 menuTree + permissions(按角色)。
+func TestIssue_WithMenuProvider(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "menuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "academic", Status: "active",
+	})
+
+	// 注入假解析器:academic 角色 2 权限码 + 1 菜单节点
+	fx.svc.menuProvider = &fakeMenuProvider{
+		codes: map[string][]string{"academic": {"org.institution.list", "org.campus.list"}},
+		tree: map[string][]rbac.MenuNode{
+			"academic": {{Code: "org.institution.list", Label: "机构管理", Path: strPtr("/org/institutions")}},
+		},
+	}
+
+	step1, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "menuser", "pw")
+	if err != nil {
+		t.Fatalf("Login: %v", err)
+	}
+	pair, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	if len(pair.Permissions) != 2 || pair.Permissions[0] != "org.institution.list" {
+		t.Fatalf("Issue.Permissions=%v(期望 2 个 org 权限码)", pair.Permissions)
+	}
+	if len(pair.MenuTree) != 1 || pair.MenuTree[0].Code != "org.institution.list" {
+		t.Fatalf("Issue.MenuTree=%+v(期望 1 个机构管理菜单)", pair.MenuTree)
+	}
+}
+
+// TestMe_WithMenuProvider 注入 MenuProvider 后 Me 出参含 menuTree + permissions(菜单刷新恢复)。
+func TestMe_WithMenuProvider(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "memuuser")
+	fx.svc.menuProvider = &fakeMenuProvider{
+		codes: map[string][]string{"super_admin": {"org.institution.list", "org.campus.list", "sys.menu"}},
+		tree: map[string][]rbac.MenuNode{
+			"super_admin": {
+				{Code: "org.institution.list", Label: "机构管理"},
+				{Code: "org.campus.list", Label: "校区管理"},
+			},
+		},
+	}
+
+	claims := token.Claims{Sub: uid, Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0}
+	me, err := fx.svc.Me(ctx, claims)
+	if err != nil {
+		t.Fatalf("Me: %v", err)
+	}
+	if len(me.Permissions) != 3 {
+		t.Fatalf("Me.Permissions=%v(超管期望 3 个权限码)", me.Permissions)
+	}
+	if len(me.MenuTree) != 2 {
+		t.Fatalf("Me.MenuTree=%+v(超管期望 2 个顶级菜单)", me.MenuTree)
+	}
+}
+
+// TestIssue_NilMenuProvider MenuProvider 未注入(默认 fixture):menuTree/permissions 非 nil 空集(不阻断)。
+func TestIssue_NilMenuProvider(t *testing.T) {
+	fx := newFixture(t) // 不注入 menuProvider
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "nilmenu")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "nilmenu", "pw")
+	pair, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	if pair.MenuTree == nil || pair.Permissions == nil {
+		t.Fatalf("MenuTree/Permissions 必须非 nil(实际 %v / %v),空集应是 [] 而非 null", pair.MenuTree, pair.Permissions)
+	}
+	if len(pair.MenuTree) != 0 || len(pair.Permissions) != 0 {
+		t.Fatalf("nil MenuProvider 时 menuTree/permissions 应空集(实际 %d / %d)", len(pair.MenuTree), len(pair.Permissions))
+	}
+}
+
+// strPtr 字符串指针辅助(测试用)。
+func strPtr(s string) *string { return &s }
+
 // ---- Refresh ----
 
 // TestRefresh_Happy 用有效 refresh 轮换 → 新 TokenPair。

+ 133 - 5
internal/org/handler/org_e2e_test.go

@@ -48,10 +48,15 @@ import (
 	infra "yuansiwei/unified-backend/internal/infra/db"
 	appredis "yuansiwei/unified-backend/internal/infra/redis"
 	"yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
+	orgRepo "yuansiwei/unified-backend/internal/org/repository"
 	orgservice "yuansiwei/unified-backend/internal/org/service"
 )
 
-// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由)。
+// ptrStr 字符串指针辅助(e2e 测试用,构造可空字段的指针值)。
+func ptrStr(s string) *string { return &s }
+
+// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由 + RBAC 解析器)。
 type orgEnv struct {
 	t          *testing.T
 	app        *fiber.App
@@ -59,6 +64,46 @@ type orgEnv struct {
 	revocation *appredis.RevocationStore
 	gdb        *gorm.DB
 	issuer     *token.Issuer
+	resolver   *rbac.Resolver // T6.5-d:真 DB 菜单树/权限码解析(超管配置端点 + menuTree 下发 e2e)
+}
+
+// newE2eResolver 构造真 DB 的 RBAC 解析器(平台级 PermissionRepo + TTL 0=不缓存,测试每次重读)。
+func newE2eResolver(gdb *gorm.DB) *rbac.Resolver {
+	return rbac.NewResolver(orgRepo.NewPermissionRepo(gdb), 0)
+}
+
+// seedRBAC 落最小集权限点 + 角色矩阵(超管配置端点 + menuTree 下发 e2e 用)。
+//   - 权限点:org.institution.list(menu)/ org.institution.get(button)/ org.campus.list(menu)/
+//     org.campus.get(button),对齐 core-model-ddl.sql v1.1 最小集种子;
+//   - 矩阵:academic 拥有 4 个 org 权限点(菜单 2 + 按钮 2)。
+func (e *orgEnv) seedRBAC() {
+	e.t.Helper()
+	ctx := context.Background()
+	now := time.Now()
+	must := func(err error, what string) {
+		if err != nil {
+			e.t.Fatalf("%s: %v", what, err)
+		}
+	}
+	instList := "org.institution.list"
+	instGet := "org.institution.get"
+	campusList := "org.campus.list"
+	campusGet := "org.campus.get"
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: instList, Type: model.PermissionTypeMenu, Name: "机构管理", Path: ptrStr("/org/institutions"), Sort: 1, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.institution.list")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: instGet, Type: model.PermissionTypeButton, Name: "机构详情", ParentCode: ptrStr(instList), Sort: 2, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.institution.get")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: campusList, Type: model.PermissionTypeMenu, Name: "校区管理", Path: ptrStr("/org/campuses"), Sort: 3, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.campus.list")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: campusGet, Type: model.PermissionTypeButton, Name: "校区详情", ParentCode: ptrStr(campusList), Sort: 4, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.campus.get")
+	// 矩阵:academic 角色拥有全部 4 个 org 权限点
+	repo := orgRepo.NewPermissionRepo(e.gdb)
+	must(repo.SaveRolePermissions(ctx, "academic", []string{instList, instGet, campusList, campusGet}), "配置 academic 矩阵")
 }
 
 // orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
@@ -73,10 +118,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 		t.Fatalf("NewDB: %v", err)
 	}
 	t.Cleanup(func() { _ = infra.Close(gdb) })
-	if err := infra.Migrate(gdb, &model.Institution{}, &model.Campus{}); err != nil {
+	// 迁移 org 域全 7 表(含 RBAC:t_permission/t_role_permission,T6.5-d 路由白名单 + 菜单树 e2e 用)
+	if err := infra.Migrate(gdb, model.AllTables()...); err != nil {
 		t.Fatalf("Migrate: %v", err)
 	}
-	t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_institution", "t_campus") })
+	t.Cleanup(func() {
+		_ = gdb.Migrator().DropTable(
+			"t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
+			"t_permission", "t_role_permission",
+		)
+	})
 
 	mr, err := miniredis.Run()
 	if err != nil {
@@ -111,13 +162,15 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 
 	app := fiber.New(fiber.Config{DisableStartupMessage: true})
 	org := app.Group("/org")
-	org.Use(mw)
+	// 验签 + 路由角色白名单(T6.5-d 接口强制层):org 目录允许 staff 角色(academic/finance/teacher),
+	// 业务级(student/parent)拒绝 1003,超管经 SuperAdmin 恒放行。whitelist 与 main.go 生产路由一致。
+	org.Use(mw, idhandler.RequireRole("academic", "finance", "teacher"))
 	org.Get("/institutions", h.Institutions)
 	org.Get("/institutions/:institutionId", h.Institution)
 	org.Get("/campuses", h.Campuses)
 	org.Get("/campuses/:campusId", h.Campus)
 
-	return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer}
+	return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer, resolver: newE2eResolver(gdb)}
 }
 
 // genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
@@ -323,3 +376,78 @@ func TestOrgE2E_ScopeMissingID(t *testing.T) {
 		t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
 	}
 }
+
+// TestOrgE2E_RouteRoleWhitelist 路由角色白名单(T6.5-d 接口强制层):
+// 白名单 [academic, finance, teacher] + 超管放行:
+//   - student(业务级,不在白名单)→ 1003(即使有有效 token);
+//   - teacher(在白名单)→ 200(正常放行,进入数据层 scoping);
+//   - super_admin(超管恒放行)→ 200(不查白名单)。
+//
+// 验证"已登录但角色不符"被中间件拦在数据层之前(code 1003 而非 1004/5002)。
+func TestOrgE2E_RouteRoleWhitelist(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+
+	// student 业务级角色:有有效 token 但角色不在 /org 白名单 → 1003
+	student := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"student"}, InstitutionID: 11, CampusID: 3,
+	})
+	if r := e.get("/org/institutions", student); r.Code != 1003 {
+		t.Fatalf("student 访问 /org/institutions code=%d(期望 1003 无权限,路由白名单拦截)", r.Code)
+	}
+	if r := e.get("/org/campuses", student); r.Code != 1003 {
+		t.Fatalf("student 访问 /org/campuses code=%d(期望 1003)", r.Code)
+	}
+
+	// teacher 在白名单:正常放行(进入数据层 scoping,返回本机构/校区)
+	teacher := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	if r := e.get("/org/institutions", teacher); r.Code != 200 {
+		t.Fatalf("teacher(白名单内)访问 /org/institutions code=%d(期望 200)", r.Code)
+	}
+
+	// super_admin:超管恒放行(不查白名单)
+	super := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
+	})
+	if r := e.get("/org/institutions", super); r.Code != 200 {
+		t.Fatalf("super_admin 访问 /org/institutions code=%d(期望 200 超管放行)", r.Code)
+	}
+}
+
+// TestOrgE2E_MenuTreeRealDB 真 DB 菜单树/权限码解析(T6.5-d 展示层 e2e):
+// 种最小集权限点 + academic 矩阵后,Resolver 解析 academic 角色 →
+//   - Permissions = 4 个 code(2 菜单 + 2 按钮);
+//   - MenuTree = 2 个顶级 menu 节点(button 不进树)。
+//
+// 验证 PermissionRepo + rbac.Resolver 在真 GORM 仓储下联动正确。
+func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedRBAC()
+	ctx := context.Background()
+
+	codes, err := e.resolver.Permissions(ctx, "academic")
+	if err != nil {
+		t.Fatalf("Permissions: %v", err)
+	}
+	if len(codes) != 4 {
+		t.Fatalf("academic Permissions=%v(期望 4 个 org 权限码)", codes)
+	}
+
+	tree, err := e.resolver.MenuTree(ctx, "academic")
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if len(tree) != 2 {
+		t.Fatalf("academic MenuTree 顶级=%v(期望 2:机构/校区,button 不进树)", tree)
+	}
+	// 超管全量:同样 4 权限码(代码层放行,不查矩阵)
+	superCodes, err := e.resolver.Permissions(ctx, "super_admin")
+	if err != nil {
+		t.Fatalf("Permissions(super_admin): %v", err)
+	}
+	if len(superCodes) != 4 {
+		t.Fatalf("super_admin Permissions=%v(期望全量 4 个)", superCodes)
+	}
+}