liuyun 802851a69f chore(openspec): track close-phase-0 implementation
sysmgr-nest 收口:RBAC 权限落地 + 审计/登录日志 + seed 脚本。
openspec change close-phase-0 任务全部完成,待归档。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 23:20:02 +08:00

6.8 KiB
Raw Blame History

Context

Phase 0 后端已具备:Prisma 6 + MySQL(14 张 sys_ 表)、登录/刷新/登出(JWT access 2h + refresh 7d 旋转)、多租户/软删/data_scope 过滤(Prisma $extends + AsyncLocalStorage 上下文)、10 个域 CRUD、pino 日志、Swagger。@RequirePermissions / @Public 装饰器与 PermissionsGuard 已定义但未接线;sys_audit_log / sys_login_log 表已存在但无写入点;无 seed 脚本。动机见 proposal.md。

关键现状约束:

  • 权限/角色/资源/组织/租户用 code 关联,用户用 id(见 shared/data-model)。
  • PrismaService 对 create 不注入 tenant_code(避免 $extends 误注入 where),故写审计/登录日志需显式传 tenantCode。
  • 无 Redis,仅 MySQL;权限集载入方案受此约束。

Goals / Non-Goals

Goals:

  • 三级鉴权落地:@Public / 无标记(仅登录)/ @RequirePermissions(登录+权限码)。
  • 权限码数据驱动:登录时从 role_resource 聚合 type ∈ {button, api} 的 resource.code。
  • super_admin 角色绕过权限码校验、data_scope 看全量。
  • 审计日志、登录日志自动落库(异步、不阻塞业务)。
  • 幂等 seed 脚本,一键初始化完整可登录的租户。

Non-Goals:

  • 不做权限变更的即时生效(如每次请求查库、强制下线)。权限变更在下次登录/刷新后生效。
  • 不做「在线会话管理 / 踢人下线」的 UI(SESSION 域后置)。
  • 不改 DB schema、不改 shared 三层主 spec、不动前端。

Decisions

D1. 权限集与超管标志存 Redis(而非 JWT / 每请求查库)

登录成功时聚合权限码(user_role → role_resource → resource,取 type ∈ {button, api} 的 code)与 super 标志,写入 Redis;PermissionsGuard 每请求从 Redis 读取做集合判断。

  • key:{REDIS_KEY_PREFIX}perm:{tenantCode}:{userId};value:{ super: boolean, permissions: string[] }
  • TTL 随会话(默认 7d,与 refresh 一致),刷新时续期,登出时删除。
  • 备选:JWT payload 承载(token 随资源数线性膨胀、权限变更延迟 ≤2h);每请求查库聚合(无缓存层、每请求多次 JOIN)。均放弃。
  • 取舍:引入 Redis 依赖,换来 token 最小、权限实时、多实例共享一致。

D2. 权限实时失效(授权变更删缓存)

修改 role_resource / user_role(授权变更)后,删除该租户全部用户的权限缓存(SCAN {prefix}perm:{tenantCode}:* 后 DEL);下一次请求守卫重新聚合并回填。

  • 简化:租户级失效,不精确到受影响用户;脚手架规模可接受。
  • 移除某人的 super_admin 角色同样经此路径即时生效(无需等 token 过期)。

D3. 权限码语义:一个 code = 一个权限点,按钮与接口分用

resource.code 全局唯一,细粒度 domain:action。约定:

  • api 资源:每个受控接口一个,code 即接口权限码(如 user:create),@RequirePermissions 引用它。
  • button 资源:仅用于「纯前端按钮」(无独立受控接口的 UI 动作,如 user:export)。前端按钮若触发受控接口,直接复用该接口的 code 判定可见性,不重复登记 button 资源。
  • menu 资源:导航菜单,code 不进入接口权限集。
  • 避免「创建用户」按钮与「POST /users」接口争抢同一 code 的唯一键冲突。

D4. 审计写入:@AuditLog(module, action) 装饰器 + 拦截器,异步快照

装饰器 SetMetadata 记录 module/action;拦截器在 next.handle() 的 tap 中:

  • 请求进入时快照 tenantCode/userId/username/ip/userAgent/method/path/脱敏后的 body 与起始时间(避免异步阶段 AsyncLocalStorage 失效丢租户上下文);
  • 完成后异步 fire-and-forget 写 sys_audit_log;失败仅记告警日志,不影响响应。
  • 入参复用 BodyLoggingInterceptor 的递归脱敏(password/token/secret 等 → ***)。
  • 成功 status=success,异常 status=fail + error_message。

D5. 登录日志写入:auth.service 内联记录

登录成功/失败在 auth.service.login 的对应分支直接写 sys_login_log(成功 status=success;失败 status=fail + message 原因:密码错误/账号停用/锁定/租户不存在)。同步写失败仅告警,不改变登录结果。

D6. seed 数据建模与幂等

prisma/seed.ts 用裸 PrismaClient(不经 $extends),全部按唯一键 upsert,可重复执行:

  • 租户 touyun;根组织 touyun。
  • 角色 super_admin(data_scope=all)、可选 admin(data_scope=all)。
  • 用户 touyun(bcryptjs 哈希 Touyun@123),绑定 super_admin。
  • 资源树:菜单(工作台 + 各域导航)、api(每个受控接口一个,code=domain:action)、button(纯前端按钮)。
  • role_resource:super_admin 关联全部资源 code。
  • 密码哈希用 bcryptjs,与运行时代码同库。

D7. 权限码域清单(domain:action)

user / role / resource / org / dict / config / tenant / audit / login-log;动作 list/create/update/delete/reset-pwd/assign/assign-resource 等,按各域实际接口对齐。

D8. Redis 接入方式

客户端用 ioredis;连接与 key 前缀均来自 env:REDIS_HOST/REDIS_PORT/REDIS_DB/REDIS_PASSWORD(默认 127.0.0.1:6379)、REDIS_KEY_PREFIX(默认 sysmgr:)。多 fork 项目共用一个 Redis 实例时按前缀隔离,互不干扰。

Risks / Trade-offs

  • [新增 Redis 依赖] → Redis 已在 Docker 常驻,key 前缀隔离多项目;若某 fork 项目无 Redis,需自行提供回退(点查)能力,属后续可选。
  • [权限缓存与 DB 短暂不一致(删 key 窗口)] → 删 key 后下一次请求即回填,窗口毫秒级。
  • [异步审计可能丢日志(D4)] → 进程崩溃窗口极小;失败告警日志兜底,审计定位问题足够。
  • [审计 params 含敏感数据] → 复用脱敏,落库前过滤 password/token/secret 等。
  • [seed 覆盖既有同名数据] → upsert 语义「更新已有、补齐缺失」;生产慎跑,仅初始化用。
  • [接口漏挂 @RequirePermissions 导致裸奔] → 默认仅登录(宽松),收口只覆盖核心管理域;后续可升级为「默认拒绝」需全量盘点。

Migration Plan

  • 无 DB schema 变更;表已存在。
  • 部署步骤:① 合并代码 ② 配置 REDIS_HOST / REDIS_KEY_PREFIX 等 env(指向常驻 Redis)③ pnpm prisma:seed(幂等,可重复)④ 重启服务。
  • 回滚:git revert;seed 数据无需回滚(幂等);Redis 缓存可整体清空(登录时自动回填)。

Open Questions

  • 权限变更后是否需强制失效既有 access token(属 SESSION 域「在线会话/踢人」,后置)。