Commit aefe33ed by luoqi

refactor(auth): 契约数据权限收敛为 orgScope 唯一入口(删 clinicIds/sourceUnits 入参)

第一性:换了 org-scope 数据权限后,clinicIds/sourceUnits 是 expandOrgScope 的**展开产物**
(PAC 内部过滤维),不该出现在对外契约入参里 —— host 只该传"用户可达的 org 节点"。

- TokenExchangeUser:删 clinicIds + sourceUnits,orgScope 转必填(任意层级:集团/区域/品牌/诊所)
  诊所级即传诊所 id;没品牌概念的 host 同理。clinicIds/sourceUnits 仅留在内部 JWT AccessTokenPayload。
- issueTokens:永远走 orgScope 展开;非 jvs-dw host 用退化树(根=tenantId,无子)→
  orgScope 里诊所 id 当裸诊所透传、sourceUnits 留空、根 id=不限。删显式 fallback 分支。
- auth-login-contract.md:org_scope 从"P2 可选增强"翻正为唯一契约入口;派生表/示例/实现现状同步
- 活体三级复验:集团 []/0(不限);品牌 ["瑞尔"]/3;诊所 ["瑞尔"]/2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent e7c3b3eb
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { buildOrgTree, expandOrgScope, type OrgTree } from './org-scope';
import { buildOrgTree, buildTree, expandOrgScope, type OrgTree } from './org-scope';
import {
ApiCode,
ROLE_PERMISSIONS,
......@@ -207,10 +207,11 @@ export class AuthService {
* 契约 user 上下文 → 签发 access + refresh。**单一真理源** —— exchangeToken(真 host 换票)
* 与 mockLogin(模拟 host)共用,token 怎么铸只此一处。
*
* 标准数据权限走 orgScope:host 传任意层级 org 节点 → 按 host org 树 expandOrgScope
* 展开成 sourceUnits + clinicIds;未传 orgScope 才回退显式 sourceUnits/clinicIds。
* 两维**空数组 = 不限**(集团级 / 单命名空间 host),下游过滤层按"空=不加过滤"处理
* —— 故此处不再对空 clinicIds 兜底成 ['default'](那会把集团级误锁成不存在的诊所)。
* 数据权限唯一入口 = orgScope(任意层级 org 节点)。按 host org 树 expandOrgScope
* 展开成内部过滤维 sourceUnits + clinicIds(两维**空 = 不限**,下游过滤层"空=不加过滤")。
* jvs-dw 有派生树;其它 host 暂用退化树(根=tenantId,无子)—— orgScope 里的诊所 id 当裸
* 诊所透传(clinicIds),sourceUnits 留空(单命名空间),根 id(=tenantId)= 不限。
* 通用态本应从摄入数据派生该 host 的树,接入新 host 时补。
*/
private async issueTokens(
host: { id: string; name: string },
......@@ -218,16 +219,12 @@ export class AuthService {
userId: string;
tenantId: string;
role: UserRole;
clinicIds: string[];
sourceUnits?: string[];
orgScope?: string[];
orgScope: string[];
dictionary?: TokenDictionary;
},
): Promise<{ accessToken: string; refreshToken: string }> {
const orgTree = host.name === 'jvs-dw' ? this.jvsDwOrgTree() : null;
const expanded = user.orgScope && orgTree ? expandOrgScope(orgTree, user.orgScope) : null;
const sourceUnits = expanded ? expanded.sourceUnits : (user.sourceUnits ?? []);
const clinicIds = expanded ? expanded.clinicIds : user.clinicIds;
const tree = host.name === 'jvs-dw' ? this.jvsDwOrgTree() : buildTree({ id: user.tenantId });
const { sourceUnits, clinicIds } = expandOrgScope(tree, user.orgScope);
const permissions = this.resolvePermissions(user.role);
const accessToken = await this.signAccessToken({
......@@ -362,7 +359,6 @@ export class AuthService {
userId: sub,
tenantId: preset.tenantId,
role: req.role,
clinicIds: [], // 用 orgScope,显式 clinicIds 不参与
orgScope,
dictionary,
});
......
......@@ -23,21 +23,27 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC
"name": "韩医生", // 展示名
"role": "leader" // staff | leader | admin(PAC 规范角色)
},
"source_units": ["瑞尔"], // 用户可见品牌(数据范围)。空数组 = 不限品牌(集团级角色)
"clinic_ids": ["C1","C2"] // 用户可达诊所
"org_scope": ["瑞尔"] // 数据权限(唯一入口):用户可达的 org 节点 id,任意层级
}
```
字段说明:
- **`group_id`**:集团 = 租户,稳定 slug(`ruier-grp`),**不用 UUID**(各环境一致,不写死)。
- **`source_units`**:用户能看哪些品牌。瑞尔员工=`["瑞尔"]`;集团管理员=`[]`(不限,看全集团)。
这就是「品牌共享/数据权限」——要不要跨品牌,由 host 在这里给。**PAC 不另设共享开关。**
- **`clinic_ids`**:诊所数据范围(plan 按 `target_clinic_id` 圈)。
- **`role`**:决定功能权限(能否分配/回收/看统计),PAC 用 `ROLE_PERMISSIONS` 静态码表派生。
> **可选增强(P2,需要时)**:host 若只知道「用户挂在哪个组织节点」(如区域),可改传
> `org_scope: ["R-east"]`,PAC 用 org_tree(yaml)下展成 `clinic_ids` + 上溯成 `source_units`。
> 当前最简契约让 host 直接给 `source_units` + `clinic_ids`,不依赖 org_tree。
- **`org_scope`**:**数据权限的唯一入口** —— 用户可达的 org 节点 id 列表,**任意层级**:
集团 / 区域(可多级)/ 品牌 / 诊所,混传也行,取并集。
- 集团管理员 = `[]``["ruier-grp"]`(命中根 = 不限,看全集团);
- 瑞尔主管 = `["瑞尔"]`(品牌级,展开成该品牌全部诊所);
- 某区域负责人 = `["华东大区"]`(区域级,展开成该区子树所有品牌+诊所);
- 诊所客服 = `["C1","C2"]`(诊所级);
- 没有品牌概念的 host 同理,只传诊所 id 即可。
PAC 按 host org 树 `expandOrgScope` 把它展开成内部两维过滤(品牌 `source_units` + 诊所
`clinic_ids`)。host **不传** `source_units`/`clinic_ids` —— 那是 PAC 的内部派生产物,
不是契约输入。要不要跨品牌(品牌共享/数据权限)= host 在 `org_scope` 里给到哪层,**无另设开关**
> **第一性**:host 只声明它唯一知道的「这个用户挂在组织树的哪些节点」;层级结构、节点→品牌/诊所
> 的下展,全由 PAC 从该 host 的 org 树派生(jvs-dw 从摄入数据派生;接新 host 时补)。
> 早期版本让 host 直接传 `source_units`+`clinic_ids`,那是把 PAC 内部过滤维泄漏进了契约,已废弃。
---
......@@ -47,8 +53,8 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC
|---|---|---|
| `group_id` | `tenantId` | 直接用 |
| `role` | `permissions` | `ROLE_PERMISSIONS[role]`(代码级,固定词表) |
| `source_units` | token.sourceUnits | 直接带(空=不限) |
| `clinic_ids` | token.clinicIds | 直接带 |
| `org_scope` | token.sourceUnits | `expandOrgScope(tree, org_scope)` → 品牌维(空=不限) |
| `org_scope` | token.clinicIds | `expandOrgScope(tree, org_scope)` → 诊所维(空=不限) |
| (names) | dictionary | clinic/user 名走 host 字典;品牌/集团名走 yaml |
---
......@@ -69,8 +75,8 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC
每请求 → `tenant-scope.interceptor` 取出 → `scope`(供 service + Prisma 纵深防御)。
**契约 token 构造器 = `auth.service.signAccessToken(payload)`** —— mock 登录、refresh 重签、
未来真 host 端点,**都调它**,单一来源
**契约签发单一来源 = `auth.service.issueTokens(host, user)`**(orgScope 展开 + 签 access/refresh)
—— mock 登录、未来真 host 端点都调它;refresh 重签复用已展开的过滤维。token 形状只此一处
---
......@@ -78,11 +84,11 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC
| 角色 | 实现 | 输入来源 |
|---|---|---|
| **mock 登录**(dev) | ✅ 已实现,产出本契约 token | 预设:brand slug → group_id/source_units/clinic_ids |
| **真 host SSO**(prod) | host 接入时实现(薄适配器) | host SSO → 本契约字段 → `signAccessToken` |
| **mock 登录**(dev) | ✅ 已实现,产出本契约 token | 预设:brand slug / orgLevel → `org_scope` |
| **真 host SSO**(prod) | host 接入时实现(薄适配器) | host SSO → 契约字段(含 `org_scope`)→ `issueTokens` |
mock 和真 host **共用 `signAccessToken` + 同一 token 形状**;差别仅在"输入来源"。
→ 真 host 接入 = 写一个「host SSO 数据 → 契约字段」的映射 + 一个端点调 `signAccessToken`,**输出不变**
mock 和真 host **共用 `issueTokens`(orgScope 展开 + 签发)+ 同一 token 形状**;差别仅在"输入来源"。
→ 真 host 接入 = 写一个「host SSO 数据 → 契约字段」的映射 + 一个端点调 `issueTokens`,**输出不变**
---
......
......@@ -32,19 +32,12 @@ export const TokenExchangeUserSchema = z
role: UserRoleSchema.describe(
'PAC role granted to this user. PAC ships three: staff (执行客服) / leader (主管) / admin (后台管理员)',
),
clinicIds: z
.array(z.string())
.describe('Clinic ids the user is authorized for'),
sourceUnits: z
.array(z.string())
.optional()
.describe('集团模型:用户可见品牌(source_unit)。空/缺省 = 不限品牌(集团级角色)'),
orgScope: z
.array(z.string())
.optional()
.describe(
'标准数据权限(可选,优先于 sourceUnits/clinicIds):用户可达的 org 节点(集团/品牌/诊所,任意层级)。' +
'PAC 按 host org 树展开成 sourceUnits + clinicIds。空 = 不限(集团级)',
'数据权限(唯一入口):用户可达的 org 节点 id,任意层级 —— 集团 / 区域(可多级)/ 品牌 / 诊所。' +
'PAC 按 host org 树 expandOrgScope 展开成内部过滤维(sourceUnits + clinicIds)。' +
'空数组 = 不限(集团级 / 超管)。诊所级即传诊所 id 列表;没有品牌概念的 host 同理只传诊所 id。',
),
dictionary: TokenDictionarySchema.optional().describe(
'Optional id→name dictionary for UI display (clinics / users)',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment