- 归档 close-phase-0(RBAC 权限 + 审计/登录日志 + seed 收口) - 新增主 spec:backend/auth、backend/res、backend/audit、backend/login - 追踪 sysmgr-nest passwordHash 泄漏修复 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.8 KiB
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 域「在线会话/踢人」,后置)。