import { Me } from './api/models'; /** * ACL 判级 —— **只决定「请求哪个 scope」**,不做任何视图放行/拒绝 * * 依据《API-契约.md》§五 判级链与 SPEC FR-20。 * * ## 🔴 与 FR-20 AC-20.2 的边界(评审必读) * * 契约要求「前端代码中**无**基于 `roles` 的**授权分支**(判据:grep 命中 0)」, * 指的是**不得**在前端用 roles 决定「这个视图/数据能不能给用户看」。 * * 本文件是**合规的**,理由: * · 它只回答「向服务端请求哪个范围」(`scope` 是**请求参数**,不是放行判据) * · 真正的可见范围判定**一律由服务端**返回结果决定(越权应返回 403,不是空列表) * · `roles` 缺失/异常时**回落最保守的 `mine`**,不存在「猜错了就放开」的路径 * * 即:roles 是**期望值**输入,不是**授权**依据(契约 §五 原文口径)。 * * ## 判级链(顺序不可调换) * * ``` * roles 含 'masters' | 'FMODE_AGENT_SUPERADMIN' → 'all' 超管:跨租户全量 * roles 含 '_Admin' → 'company' 公司管理员:本公司 * 否则 → 'mine' 普通用户:仅本人 * ``` */ /** 向服务端请求的可见范围(云函数 `xlosi3m46q` 的 `scope` 参数) */ export type Scope = 'all' | 'company' | 'mine'; /** 身份级别(与 v1 `fmode-app/src/app/core/acl.ts` 对齐) */ export type AclLevel = 'guest' | 'superadmin' | 'company-admin' | 'company-member' | 'personal'; /** 判级结果:级别 + 展示标签 + 所属公司 */ export interface AclScope { level: AclLevel; label: string; companyId?: string; } /** 跨租户超管角色名(与《API-契约》§五 一致,顺序无关) */ const SUPERADMIN_ROLES = ['masters', 'FMODE_AGENT_SUPERADMIN']; /** * 判级 —— 对照 v1 `fmode-app/src/app/core/acl.ts:50-69` 逐条移植 * * ``` * ① 无 me → guest * ② roles 含超管角色 → superadmin(不要求有公司) * ③ username === 超管白名单 → superadmin * ④ 有 company + _Admin → company-admin * ⑤ 有 company → company-member * ⑥ 其余 → personal * ``` * * 🔴 **管理员与成员必须区分**(`company-admin` vs `company-member`)—— * 虽然**当前**两者权限相同(都请求 `scope='company'`), * 但将来若收紧为「成员只看自己」,只需改 `scopeOf()` 一处,不动判级逻辑。 */ export function resolveScope(me: Me | null | undefined): AclScope { if (!me) return { level: 'guest', label: '未登录' }; const roles = me.roles ?? []; const companyId = me.companyId; // ② 超管(角色)—— 不要求有公司 if (roles.some((r) => SUPERADMIN_ROLES.includes(r))) { return { level: 'superadmin', label: '系统超管' }; } // ③ 超管(username 白名单,与 v1 SUPERADMIN_USERNAME 一致) if (me.name === 'FMODE_AGENT_SUPERADMIN') { return { level: 'superadmin', label: '系统超管' }; } // ④/⑤ 有公司:区分管理员 vs 成员 if (companyId) { const isAdmin = roles.includes(`${companyId}_Admin`); return { level: isAdmin ? 'company-admin' : 'company-member', label: isAdmin ? '公司管理员' : '公司成员', companyId, }; } // ⑥ 无公司 → 个人 return { level: 'personal', label: '个人用户' }; } /** * 身份级别 → 请求 scope。 * * | level | scope | 说明 | * |---|---|---| * | superadmin | `all` | 全部容器 | * | company-admin | `company` | 本公司 + 自己的(兜底)| * | company-member | `mine` | **仅自己**(2026-10-31 用户裁决:普通成员不应看到同事的容器)| * | guest / personal | `mine` | 仅自己 | * * 🔴 2026-10-31 隔离收紧:普通成员此前与管理员一样请求 `company`, * 导致同公司所有人都能看到彼此的容器(用户实测:看到其他人的 lj-test / 兔爷001)。 * 现在成员统一请求 `mine`;服务端 `xlosi3m46q` 本来就把 `mine` 映射为 * `user === 当前用户` 的强制过滤,因此这是**更小**的请求范围,不存在越权放大。 * 无 company 的容器也归 `mine` 覆盖(`q.equalTo('user', user)`),不会漏看。 */ export function scopeOf(scope: AclScope): Scope { switch (scope.level) { case 'superadmin': return 'all'; case 'company-admin': return 'company'; case 'company-member': /* * 普通成员只看自己的(2026-10-31 用户裁决)。 * 🔴 不要改回 company:那会把整个公司的容器暴露给每个成员。 */ return 'mine'; default: return 'mine'; } } /** * 判定请求 scope(`resolveScope` + `scopeOf` 的组合,保留旧调用签名)。 * * 🔴 **`me` 为 null 时返回 `'mine'`** —— 安全默认:拿不到判级依据时按最小权限请求。 */ export function scopeFor(me: Me | null | undefined): Scope { return scopeOf(resolveScope(me)); }