# PAC 登录对外契约(集团模型)

> host 接入 PAC 的登录输入规范。mock 登录 = 本契约的开发期模拟实现(同一套派生 + token)。
> 关联:[tenant-group-org-model-refactor.md](tenant-group-org-model-refactor.md)

---

## 0. 一句话

host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC;PAC 派生权限、签发 token。
**host 只声明它唯一知道的(谁、什么角色、能看哪些品牌/诊所),结构性的东西 PAC 自己派生。**

---

## 1. 输入:Host → PAC(登录时)

```jsonc
{
  "host_id":  "jvs-dw",          // 接入宿主(对应 PAC hosts.name)
  "group_id": "ruier-grp",       // 集团 = PAC 租户(稳定 slug,各环境一致;不是 UUID)
  "user": {
    "id":   "wx_woqj...",        // 宿主侧用户唯一 id
    "name": "韩医生",             // 展示名
    "role": "leader"             // staff | leader | admin(PAC 规范角色)
  },
  "org_scope": ["瑞尔"]          // 数据权限(唯一入口):用户可达的 org 节点 id,任意层级
}
```

字段说明:
- **`group_id`**:集团 = 租户,稳定 slug(`ruier-grp`),**不用 UUID**(各环境一致,不写死)。
- **`role`**:决定功能权限(能否分配/回收/看统计),PAC 用 `ROLE_PERMISSIONS` 静态码表派生。
- **`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 内部过滤维泄漏进了契约,已废弃。

---

## 2. PAC 派生(host 不用管)

| host 给的 | PAC 派生 | 怎么派生 |
|---|---|---|
| `group_id` | `tenantId` | 直接用 |
| `role` | `permissions` | `ROLE_PERMISSIONS[role]`(代码级,固定词表) |
| `org_scope` | token.orgScope | 透传(JWT 与契约同词汇,不泄漏内部 schema) |
| `org_scope` | scope.sourceUnits / clinicIds | **每请求** `expandOrgScope(tree, org_scope)` 展开成过滤维(空=不限) |
| (names) | dictionary | clinic/user 名走 host 字典;品牌/集团名走 yaml |

---

## 3. 输出:Token(`AccessTokenPayload`)

JWT 只装**通用 `orgScope`**(与契约同词汇)—— 解码出来跟 host 发进来的一致,不泄漏 PAC 内部
schema(`source_unit`/`clinic_id`):

```jsonc
{
  "sub": "wx_woqj...", "hostId": "<uuid>",
  "tenantId": "ruier-grp",          // 集团
  "orgScope": ["瑞尔"],             // = host 发的 org_scope(任意层级;空=不限)
  "role": "leader", "permissions": ["plan:view_all", ...],
  "dictionary": { "clinics": {...}, "users": {...} },
  "iat": ..., "exp": ...
}
```

每请求 → `tenant-scope.interceptor`(REST)/ `mcp-auth`(MCP)把 `orgScope` 按 host org 树
**展开**成 `scope.{sourceUnits, clinicIds}`(供 service + Prisma 纵深防御)。
前端展不开 orgScope → 调 `GET /auth/session` 拿后端展开后的 `clinicIds/sourceUnits`(诊所筛选用)。

**契约签发单一来源 = `auth.service.issueTokens(host, user)`**(orgScope 展开 + 签 access/refresh)
—— mock 登录、未来真 host 端点都调它;refresh 重签复用已展开的过滤维。token 形状只此一处。

---

## 4. 实现现状

| 角色 | 实现 | 输入来源 |
|---|---|---|
| **mock 登录**(dev) | ✅ 已实现,产出本契约 token | 预设:brand slug / orgLevel → `org_scope` |
| **真 host SSO**(prod) | host 接入时实现(薄适配器) | host SSO → 契约字段(含 `org_scope`)→ `issueTokens` |

mock 和真 host **共用 `issueTokens`(orgScope 展开 + 签发)+ 同一 token 形状**;差别仅在"输入来源"。
→ 真 host 接入 = 写一个「host SSO 数据 → 契约字段」的映射 + 一个端点调 `issueTokens`,**输出不变**。

---

## 5. 隔离保证(token 怎么落到数据范围)

- **集团隔离(硬)**:`tenantId` 进每张表唯一键 + 每条查询 where;Prisma 纵深防御扩展对漏写的读查询自动注入 `tenantId`(ALS 上下文)。
- **品牌范围(数据权限)**:`sourceUnits` → 患者粒度过滤(搜索/列表/by-id + 扩展自动注入 source_unit 模型)。空 = 不限(集团级角色)。
- **诊所范围**:`clinicIds` → plan 按 `target_clinic_id` 圈。
- **功能权限**:`role → permissions`,`PermissionsGuard` 路由级校验。
