Commit 7fd4c0d8 by luoqi

refactor(auth): JWT 改装通用 orgScope,过滤维每请求展开(设计 B)

JWT 是 host 也能解码的凭证,原先装展开后的 sourceUnits/clinicIds(PAC 内部 schema 词汇),
与"host 只发 org_scope"的契约不一致、泄漏内部表示。改为 JWT 只装 orgScope(与契约同词汇),
过滤维留到每请求展开:
- AccessTokenPayload / RefreshPayload:sourceUnits/clinicIds → orgScope;issueTokens 不再展开,直接签
- org-tree.ts(纯函数 expandScope:按 tenantId 选 host org 树展开)+ jvs-dw-preset.ts(树数据,与 mock 共用)
- tenant-scope.interceptor(REST)/ mcp-auth(MCP)每请求 expandScope → scope.{sourceUnits,clinicIds}
- 前端展不开 orgScope → 新增 GET /auth/session 返回展开后的 clinicIds/sourceUnits + role/permissions/dictionary;
  auth-store 加 loadSession(登录/F5 后异步补进 user),组件读 user.clinicIds 不变
- weixin-aibot 自签 token 同步改 orgScope;契约文档 §2/§3 同步

验证:JWT 解码只含 orgScope;/auth/session 展开正确;REST /plans 诊所过滤生效(石琳仅本诊所);
集团级 orgScope=[grp]→clinicIds=[]/sourceUnits=[] 不限。两端 tsc 0 错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent fd57d73d
...@@ -3,6 +3,7 @@ import { Observable } from 'rxjs'; ...@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
import type { TenantScopeContext } from '../decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../decorators/tenant-scope.decorator';
import { tenantAls } from '../tenant-context'; import { tenantAls } from '../tenant-context';
import { expandScope } from '../../modules/auth/org-tree';
/** /**
* Pulls host_id / tenant_id / clinic_ids out of the JWT payload and attaches * Pulls host_id / tenant_id / clinic_ids out of the JWT payload and attaches
...@@ -20,11 +21,13 @@ export class TenantScopeInterceptor implements NestInterceptor { ...@@ -20,11 +21,13 @@ export class TenantScopeInterceptor implements NestInterceptor {
tenantScope?: TenantScopeContext; tenantScope?: TenantScopeContext;
}>(); }>();
if (req.user) { if (req.user) {
// 设计 B:JWT 装通用 orgScope,这里按 host org 树展开成内部过滤维(sourceUnits + clinicIds)。
const { sourceUnits, clinicIds } = expandScope(req.user.tenantId, req.user.orgScope ?? []);
req.tenantScope = { req.tenantScope = {
hostId: req.user.hostId, hostId: req.user.hostId,
tenantId: req.user.tenantId, tenantId: req.user.tenantId,
clinicIds: req.user.clinicIds ?? [], clinicIds,
sourceUnits: req.user.sourceUnits ?? [], sourceUnits,
userId: req.user.sub, userId: req.user.sub,
}; };
// 纵深防御:把租户上下文存进 ALS,让 Prisma 扩展对漏写 tenantId 的读查询自动兜底。 // 纵深防御:把租户上下文存进 ALS,让 Prisma 扩展对漏写 tenantId 的读查询自动兜底。
...@@ -32,7 +35,7 @@ export class TenantScopeInterceptor implements NestInterceptor { ...@@ -32,7 +35,7 @@ export class TenantScopeInterceptor implements NestInterceptor {
const alsCtx = { const alsCtx = {
hostId: req.user.hostId, hostId: req.user.hostId,
tenantId: req.user.tenantId, tenantId: req.user.tenantId,
sourceUnits: req.user.sourceUnits ?? [], sourceUnits,
}; };
return new Observable((subscriber) => { return new Observable((subscriber) => {
tenantAls.run(alsCtx, () => { tenantAls.run(alsCtx, () => {
......
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
TenantScope,
TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
ExchangeCodeRequestDto, ExchangeCodeRequestDto,
...@@ -11,6 +17,7 @@ import { ...@@ -11,6 +17,7 @@ import {
MockUsersResponseDto, MockUsersResponseDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
RefreshTokenResponseDto, RefreshTokenResponseDto,
SessionResponseDto,
TokenExchangeRequestDto, TokenExchangeRequestDto,
TokenExchangeResponseDto, TokenExchangeResponseDto,
} from './dto/auth.dto'; } from './dto/auth.dto';
...@@ -72,4 +79,28 @@ export class AuthController { ...@@ -72,4 +79,28 @@ export class AuthController {
mockUsers() { mockUsers() {
return { users: this.auth.listMockUsers() }; return { users: this.auth.listMockUsers() };
} }
@Get('session')
@ApiBearerAuth('accessToken')
@ZodResponse({ status: 200, type: SessionResponseDto })
@ApiOperation({
summary:
'当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)',
})
session(
@CurrentUser() user: AccessTokenPayload,
@TenantScope() scope: TenantScopeContext,
) {
return {
sub: user.sub,
hostId: user.hostId,
tenantId: user.tenantId,
role: user.role,
permissions: user.permissions,
orgScope: user.orgScope,
clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开
sourceUnits: scope.sourceUnits,
...(user.dictionary ? { dictionary: user.dictionary } : {}),
};
}
} }
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { buildOrgTree, buildTree, expandOrgScope, type OrgTree } from './org-scope'; import { JVS_DW_PRESETS } from './jvs-dw-preset';
import { import {
ApiCode, ApiCode,
ROLE_PERMISSIONS, ROLE_PERMISSIONS,
...@@ -42,8 +42,7 @@ interface RefreshPayload { ...@@ -42,8 +42,7 @@ interface RefreshPayload {
sub: string; sub: string;
hostId: string; hostId: string;
tenantId: string; tenantId: string;
clinicIds: string[]; orgScope: string[];
sourceUnits?: string[];
role: UserRole; role: UserRole;
dictionary?: TokenDictionary; dictionary?: TokenDictionary;
jti: string; jti: string;
...@@ -145,8 +144,7 @@ export class AuthService { ...@@ -145,8 +144,7 @@ export class AuthService {
sub: payload.sub, sub: payload.sub,
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds, orgScope: payload.orgScope,
sourceUnits: payload.sourceUnits,
role: payload.role, role: payload.role,
permissions, permissions,
...(payload.dictionary ? { dictionary: payload.dictionary } : {}), ...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
...@@ -158,8 +156,7 @@ export class AuthService { ...@@ -158,8 +156,7 @@ export class AuthService {
sub: payload.sub, sub: payload.sub,
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds, orgScope: payload.orgScope,
sourceUnits: payload.sourceUnits,
role: payload.role, role: payload.role,
dictionary: payload.dictionary, dictionary: payload.dictionary,
}); });
...@@ -181,8 +178,7 @@ export class AuthService { ...@@ -181,8 +178,7 @@ export class AuthService {
sub: string; sub: string;
hostId: string; hostId: string;
tenantId: string; tenantId: string;
clinicIds: string[]; orgScope: string[];
sourceUnits?: string[];
role: UserRole; role: UserRole;
dictionary?: TokenDictionary; dictionary?: TokenDictionary;
}): Promise<string> { }): Promise<string> {
...@@ -207,11 +203,9 @@ export class AuthService { ...@@ -207,11 +203,9 @@ export class AuthService {
* 契约 user 上下文 → 签发 access + refresh。**单一真理源** —— exchangeToken(真 host 换票) * 契约 user 上下文 → 签发 access + refresh。**单一真理源** —— exchangeToken(真 host 换票)
* 与 mockLogin(模拟 host)共用,token 怎么铸只此一处。 * 与 mockLogin(模拟 host)共用,token 怎么铸只此一处。
* *
* 数据权限唯一入口 = orgScope(任意层级 org 节点)。按 host org 树 expandOrgScope * 设计 B:JWT 只装通用的 `orgScope`(与对外契约同词汇,不泄漏 PAC 内部 schema)。
* 展开成内部过滤维 sourceUnits + clinicIds(两维**空 = 不限**,下游过滤层"空=不加过滤")。 * **不在此展开**成 sourceUnits/clinicIds —— 那留到每个请求(tenant-scope 拦截器 / MCP 鉴权)
* jvs-dw 有派生树;其它 host 暂用退化树(根=tenantId,无子)—— orgScope 里的诊所 id 当裸 * 调 expandScope() 现展开。这样 JWT 解出来跟 host 发进来的一致。
* 诊所透传(clinicIds),sourceUnits 留空(单命名空间),根 id(=tenantId)= 不限。
* 通用态本应从摄入数据派生该 host 的树,接入新 host 时补。
*/ */
private async issueTokens( private async issueTokens(
host: { id: string; name: string }, host: { id: string; name: string },
...@@ -223,16 +217,13 @@ export class AuthService { ...@@ -223,16 +217,13 @@ export class AuthService {
dictionary?: TokenDictionary; dictionary?: TokenDictionary;
}, },
): Promise<{ accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string }> {
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 permissions = this.resolvePermissions(user.role);
const accessToken = await this.signAccessToken({ const accessToken = await this.signAccessToken({
sub: user.userId, sub: user.userId,
hostId: host.id, hostId: host.id,
tenantId: user.tenantId, tenantId: user.tenantId,
clinicIds, orgScope: user.orgScope, // 数据权限(通用词汇);请求时再展开成过滤维
sourceUnits, // 集团模型:品牌数据范围(orgScope 展开 或 显式;空=不限)
role: user.role, role: user.role,
permissions, permissions,
...(user.dictionary ? { dictionary: user.dictionary } : {}), ...(user.dictionary ? { dictionary: user.dictionary } : {}),
...@@ -241,8 +232,7 @@ export class AuthService { ...@@ -241,8 +232,7 @@ export class AuthService {
sub: user.userId, sub: user.userId,
hostId: host.id, hostId: host.id,
tenantId: user.tenantId, tenantId: user.tenantId,
clinicIds, orgScope: user.orgScope,
sourceUnits,
role: user.role, role: user.role,
dictionary: user.dictionary, dictionary: user.dictionary,
}); });
...@@ -257,30 +247,8 @@ export class AuthService { ...@@ -257,30 +247,8 @@ export class AuthService {
// 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true); // 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true);
// 生产真 host 接入后,删 endpoint 或把 env 设 false。 // 生产真 host 接入后,删 endpoint 或把 env 设 false。
/// jvs-dw 预制 user payload(集团模型:tenant=集团 slug,品牌=source_unit) /// jvs-dw 预制 user 数据(集团模型);抽到 jvs-dw-preset.ts,与 org-tree.ts 共用。
/// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"集团模型" private readonly MOCK_PRESETS = JVS_DW_PRESETS;
/// `docs/deployment-data-ingest.md` §clinic UUID 列表
private readonly MOCK_PRESETS = {
ruier: {
tenantId: 'ruier-grp', // 集团(= 租户)
sourceUnit: '瑞尔', // 该用户所属品牌(数据范围按它圈本品牌)
tenantNameZh: '瑞尔',
clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
dad2f04a120947e2b82b41cbd108f3f4: '杭州高德诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所',
},
},
ruitai: {
tenantId: 'ruier-grp', // 同集团
sourceUnit: '瑞泰',
tenantNameZh: '瑞泰',
clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
e83d432a38bb4f6284713b36db4e7497: '上海世纪公园',
},
},
} as const;
isMockLoginEnabled(): boolean { isMockLoginEnabled(): boolean {
if (process.env.PAC_ENABLE_MOCK_LOGIN === 'true') return true; if (process.env.PAC_ENABLE_MOCK_LOGIN === 'true') return true;
...@@ -288,15 +256,6 @@ export class AuthService { ...@@ -288,15 +256,6 @@ export class AuthService {
return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production'; return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production';
} }
/** jvs-dw 组织树 —— 从 MOCK_PRESETS(品牌→诊所)派生,不 hardcode。
* 通用态:本应从摄入数据派生(clinic→brand),实现对所有 host 一致。 */
private jvsDwOrgTree(): OrgTree {
return buildOrgTree('ruier-grp', {
瑞尔: Object.keys(this.MOCK_PRESETS.ruier.clinics),
瑞泰: Object.keys(this.MOCK_PRESETS.ruitai.clinics),
});
}
async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> { async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> {
if (!this.isMockLoginEnabled()) { if (!this.isMockLoginEnabled()) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用'); throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
...@@ -315,8 +274,7 @@ export class AuthService { ...@@ -315,8 +274,7 @@ export class AuthService {
); );
} }
// 集团模型:数据范围统一走 org-scope(任意层级)→ expandOrgScope 展开成 sourceUnits + clinicIds。 // 集团模型:数据范围 = orgScope(任意层级);展开留到请求时(设计 B)。
const tree = this.jvsDwOrgTree();
let sub = `mock-${req.tenant}-${req.role}`; let sub = `mock-${req.tenant}-${req.role}`;
let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role); let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
let orgScope: string[]; let orgScope: string[];
...@@ -324,10 +282,10 @@ export class AuthService { ...@@ -324,10 +282,10 @@ export class AuthService {
const allClinics = { ...this.MOCK_PRESETS.ruier.clinics, ...this.MOCK_PRESETS.ruitai.clinics }; const allClinics = { ...this.MOCK_PRESETS.ruier.clinics, ...this.MOCK_PRESETS.ruitai.clinics };
if (req.orgLevel === 'group') { if (req.orgLevel === 'group') {
// ── 集团级:看全部品牌全部诊所(sourceUnits=[] 不限)── // ── 集团级:orgScope=[集团根 id] → 展开后 sourceUnits=[]/clinicIds=[] 不限 ──
sub = `mock-group-${req.role}`; sub = `mock-group-${req.role}`;
subName = `集团${roleNameZh(req.role)}`; subName = `集团${roleNameZh(req.role)}`;
orgScope = [tree.groupId]; orgScope = [preset.tenantId];
} else if (req.userExternalId) { } else if (req.userExternalId) {
// ── 诊所级:具体客服真实身份 + 其所属诊所 ── // ── 诊所级:具体客服真实身份 + 其所属诊所 ──
const cs = loadMockUsers().find( const cs = loadMockUsers().find(
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
MockUsersResponseSchema, MockUsersResponseSchema,
RefreshTokenRequestSchema, RefreshTokenRequestSchema,
RefreshTokenResponseSchema, RefreshTokenResponseSchema,
SessionResponseSchema,
TokenExchangeRequestSchema, TokenExchangeRequestSchema,
TokenExchangeResponseSchema, TokenExchangeResponseSchema,
} from '@pac/types'; } from '@pac/types';
...@@ -20,3 +21,4 @@ export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSc ...@@ -20,3 +21,4 @@ export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSc
export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {} export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {} export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {} export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {}
export class SessionResponseDto extends createZodDto(SessionResponseSchema) {}
/**
* jvs-dw 预制组织数据(集团模型:tenant=集团 slug,品牌=source_unit,诊所=UUID)。
*
* 单一数据源,两处用:
* - auth.service mockLogin:派生 orgScope / 诊所字典 / 品牌 slug;
* - org-tree.ts:派生该 host 的 org 树(集团→品牌→诊所),供请求时 expandOrgScope。
*
* 来源:`data/jvs-dw/manifest.yaml` §"集团模型" + `docs/deployment-data-ingest.md` §clinic UUID。
* 通用态本应从摄入数据派生(clinic→brand);此处先内置 jvs-dw。
*/
export const JVS_DW_PRESETS = {
ruier: {
tenantId: 'ruier-grp', // 集团(= 租户)
sourceUnit: '瑞尔', // 品牌(= patients.source_unit)
tenantNameZh: '瑞尔',
clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
dad2f04a120947e2b82b41cbd108f3f4: '杭州高德诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所',
},
},
ruitai: {
tenantId: 'ruier-grp', // 同集团
sourceUnit: '瑞泰',
tenantNameZh: '瑞泰',
clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
e83d432a38bb4f6284713b36db4e7497: '上海世纪公园',
},
},
} as const;
/**
* host org 树解析 + scope 展开(纯函数,无 DI)。
*
* 设计 B:JWT 只装通用的 `orgScope`(与对外契约同词汇);**每个请求**在 tenant-scope
* 拦截器 / MCP 鉴权处调 expandScope() 展开成内部过滤维(sourceUnits + clinicIds)。
* 好处:JWT 解出来跟 host 发进来的一致,不泄漏 PAC schema 词汇(clinic/source_unit)。
*
* 树按 **tenantId** 选(树的 groupId 就是 tenantId,天然 key)。jvs-dw 有派生树;
* 未知 tenantId 用退化树(根=tenantId,无子)—— orgScope 里的诊所 id 当裸诊所透传、
* sourceUnits 留空(单命名空间)、根 id(=tenantId)= 不限。通用态接新 host 时补树来源。
*/
import { JVS_DW_PRESETS } from './jvs-dw-preset';
import {
buildOrgTree,
buildTree,
expandOrgScope,
type ExpandedScope,
type OrgTree,
} from './org-scope';
/** tenantId → org 树。jvs-dw 内置;其余按需(通用态从摄入数据派生)。 */
const TREES: Record<string, OrgTree> = {
[JVS_DW_PRESETS.ruier.tenantId]: buildOrgTree(JVS_DW_PRESETS.ruier.tenantId, {
[JVS_DW_PRESETS.ruier.sourceUnit]: Object.keys(JVS_DW_PRESETS.ruier.clinics),
[JVS_DW_PRESETS.ruitai.sourceUnit]: Object.keys(JVS_DW_PRESETS.ruitai.clinics),
}),
};
/** 取 host org 树;未知 tenantId → 退化树(根=tenantId,无子)。 */
export function getOrgTree(tenantId: string): OrgTree {
return TREES[tenantId] ?? buildTree({ id: tenantId });
}
/** 把 orgScope(任意层级 org 节点)展开成内部过滤维 { sourceUnits, clinicIds }。 */
export function expandScope(tenantId: string, orgScope: string[]): ExpandedScope {
return expandOrgScope(getOrgTree(tenantId), orgScope);
}
...@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { expandScope } from '../auth/org-tree';
export interface McpAuthContext { export interface McpAuthContext {
scope: TenantScopeContext; scope: TenantScopeContext;
...@@ -32,12 +33,14 @@ export class McpAuthService { ...@@ -32,12 +33,14 @@ export class McpAuthService {
if (!payload?.hostId || !payload?.tenantId) { if (!payload?.hostId || !payload?.tenantId) {
throw new UnauthorizedException('token payload 缺 host/tenant'); throw new UnauthorizedException('token payload 缺 host/tenant');
} }
// 设计 B:JWT 装通用 orgScope,这里展开成内部过滤维(与 REST 拦截器同一套)。
const { sourceUnits, clinicIds } = expandScope(payload.tenantId, payload.orgScope ?? []);
return { return {
scope: { scope: {
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds ?? [], clinicIds,
sourceUnits: payload.sourceUnits ?? [], sourceUnits,
userId: payload.sub, userId: payload.sub,
}, },
permissions: payload.permissions ?? [], permissions: payload.permissions ?? [],
......
...@@ -330,7 +330,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -330,7 +330,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
sub: `wx:${wxUserid}`, sub: `wx:${wxUserid}`,
hostId, hostId,
tenantId: this.tenantId, tenantId: this.tenantId,
clinicIds: [], // 空 = 不加诊所过滤,看该 tenant 整池(PoC);生产按客服归属诊所 orgScope: [], // 空 = 不限(看该 tenant 整池,PoC);生产按客服归属 org 节点
role, role,
permissions: this.auth.resolvePermissions(role), permissions: this.auth.resolvePermissions(role),
}); });
......
...@@ -6,9 +6,15 @@ import type { ...@@ -6,9 +6,15 @@ import type {
MockLoginResponse, MockLoginResponse,
MockUsersResponse, MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
SessionResponse,
} from '@pac/types'; } from '@pac/types';
import { api } from './api-client'; import { api } from './api-client';
/// 会话视图 — JWT 只装通用 orgScope(设计 B),诊所/品牌过滤维由后端展开后经此端点返回。
export async function getSession(): Promise<SessionResponse> {
return api.get<SessionResponse>('/pac/v1/auth/session');
}
export async function exchangeCode(code: string): Promise<ExchangeCodeResponse> { export async function exchangeCode(code: string): Promise<ExchangeCodeResponse> {
return api.post<ExchangeCodeResponse>( return api.post<ExchangeCodeResponse>(
'/pac/v1/auth/exchange-code', '/pac/v1/auth/exchange-code',
......
...@@ -4,12 +4,24 @@ import { create } from 'zustand'; ...@@ -4,12 +4,24 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
/**
* 前端会话用户 = JWT 解码(role/permissions/sub/tenantId/dictionary/orgScope,即时)
* + 会话补丁(clinicIds/sourceUnits,异步从 GET /auth/session 拉)。
* 设计 B:JWT 只装通用 orgScope,前端展不开,故诊所/品牌过滤维由后端展开后补进来。
*/
export type SessionUser = AccessTokenPayload & {
clinicIds?: string[];
sourceUnits?: string[];
};
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
expiresAt: number | null; expiresAt: number | null;
user: AccessTokenPayload | null; user: SessionUser | null;
setTokens: (input: { accessToken: string; refreshToken: string; expiresIn: number }) => void; setTokens: (input: { accessToken: string; refreshToken: string; expiresIn: number }) => void;
/** 拉 /auth/session,把展开后的 clinicIds/sourceUnits 合并进 user(诊所筛选等用) */
loadSession: () => Promise<void>;
clear: () => void; clear: () => void;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
/** refresh token 还在(可能 access 过期),允许 silent refresh */ /** refresh token 还在(可能 access 过期),允许 silent refresh */
...@@ -61,6 +73,22 @@ export const useAuthStore = create<AuthState>()( ...@@ -61,6 +73,22 @@ export const useAuthStore = create<AuthState>()(
expiresAt: Date.now() + expiresIn * 1000, expiresAt: Date.now() + expiresIn * 1000,
user: decodeJwt(accessToken), user: decodeJwt(accessToken),
}); });
// 异步补齐展开后的数据范围(不阻塞登录;拿到前诊所筛选暂为空)
void get().loadSession();
},
loadSession: async () => {
if (!get().accessToken) return;
try {
const { getSession } = await import('@/lib/auth-api');
const s = await getSession();
set((st) =>
st.user
? { user: { ...st.user, clinicIds: s.clinicIds, sourceUnits: s.sourceUnits } }
: {},
);
} catch {
// 静默:拿不到就保持 token decode 的基础 user(诊所筛选回退为空)
}
}, },
clear: () => set({ accessToken: null, refreshToken: null, expiresAt: null, user: null }), clear: () => set({ accessToken: null, refreshToken: null, expiresAt: null, user: null }),
isAuthenticated: () => { isAuthenticated: () => {
...@@ -81,6 +109,8 @@ export const useAuthStore = create<AuthState>()( ...@@ -81,6 +109,8 @@ export const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
if (state?.accessToken) { if (state?.accessToken) {
state.user = decodeJwt(state.accessToken); state.user = decodeJwt(state.accessToken);
// F5/反水合后补齐 clinicIds/sourceUnits(token decode 拿不到)
void state.loadSession();
} }
}, },
}, },
......
...@@ -53,27 +53,31 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC ...@@ -53,27 +53,31 @@ host 在用户登录时,把「**身份 + 可见范围**」按本契约传给 PAC
|---|---|---| |---|---|---|
| `group_id` | `tenantId` | 直接用 | | `group_id` | `tenantId` | 直接用 |
| `role` | `permissions` | `ROLE_PERMISSIONS[role]`(代码级,固定词表) | | `role` | `permissions` | `ROLE_PERMISSIONS[role]`(代码级,固定词表) |
| `org_scope` | token.sourceUnits | `expandOrgScope(tree, org_scope)` → 品牌维(空=不限) | | `org_scope` | token.orgScope | 透传(JWT 与契约同词汇,不泄漏内部 schema) |
| `org_scope` | token.clinicIds | `expandOrgScope(tree, org_scope)` → 诊所维(空=不限) | | `org_scope` | scope.sourceUnits / clinicIds | **每请求** `expandOrgScope(tree, org_scope)` 展开成过滤维(空=不限) |
| (names) | dictionary | clinic/user 名走 host 字典;品牌/集团名走 yaml | | (names) | dictionary | clinic/user 名走 host 字典;品牌/集团名走 yaml |
--- ---
## 3. 输出:Token(`AccessTokenPayload`) ## 3. 输出:Token(`AccessTokenPayload`)
JWT 只装**通用 `orgScope`**(与契约同词汇)—— 解码出来跟 host 发进来的一致,不泄漏 PAC 内部
schema(`source_unit`/`clinic_id`):
```jsonc ```jsonc
{ {
"sub": "wx_woqj...", "hostId": "<uuid>", "sub": "wx_woqj...", "hostId": "<uuid>",
"tenantId": "ruier-grp", // 集团 "tenantId": "ruier-grp", // 集团
"sourceUnits": ["瑞尔"], // 品牌数据范围(空=不限) "orgScope": ["瑞尔"], // = host 发的 org_scope(任意层级;空=不限)
"clinicIds": ["C1","C2"],
"role": "leader", "permissions": ["plan:view_all", ...], "role": "leader", "permissions": ["plan:view_all", ...],
"dictionary": { "clinics": {...}, "users": {...} }, "dictionary": { "clinics": {...}, "users": {...} },
"iat": ..., "exp": ... "iat": ..., "exp": ...
} }
``` ```
每请求 → `tenant-scope.interceptor` 取出 → `scope`(供 service + Prisma 纵深防御)。 每请求 → `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) **契约签发单一来源 = `auth.service.issueTokens(host, user)`**(orgScope 展开 + 签 access/refresh)
—— mock 登录、未来真 host 端点都调它;refresh 重签复用已展开的过滤维。token 形状只此一处。 —— mock 登录、未来真 host 端点都调它;refresh 重签复用已展开的过滤维。token 形状只此一处。
......
...@@ -160,10 +160,11 @@ export const AccessTokenPayloadSchema = z.object({ ...@@ -160,10 +160,11 @@ export const AccessTokenPayloadSchema = z.object({
sub: z.string(), sub: z.string(),
hostId: z.string(), hostId: z.string(),
tenantId: z.string(), tenantId: z.string(),
clinicIds: z.array(z.string()), /// 数据权限(与对外契约同词汇):用户可达的 org 节点 id,任意层级。
/// 集团模型:用户所属源组织命名空间(品牌)。数据范围在患者粒度按它圈本品牌; /// PAC 在每个请求(tenant-scope 拦截器 / MCP 鉴权)按 host org 树展开成内部过滤维
/// 空数组 = 不限品牌(集团级角色 / 单命名空间 host)。缺省视为不限(向后兼容老 token)。 /// (sourceUnits + clinicIds)。空数组 = 不限(集团级)。
sourceUnits: z.array(z.string()).optional(), /// 故 JWT 解出来跟 host 发进来的 org_scope 一致 —— 不泄漏 PAC 内部 schema 词汇。
orgScope: z.array(z.string()),
role: UserRoleSchema, role: UserRoleSchema,
permissions: z.array(z.string()), permissions: z.array(z.string()),
/// 可选名字典(clinics/users id→名),host 换票时带,前端 UI 用于把 UUID 显示成名字。 /// 可选名字典(clinics/users id→名),host 换票时带,前端 UI 用于把 UUID 显示成名字。
...@@ -173,3 +174,25 @@ export const AccessTokenPayloadSchema = z.object({ ...@@ -173,3 +174,25 @@ export const AccessTokenPayloadSchema = z.object({
exp: z.number().int(), exp: z.number().int(),
}); });
export type AccessTokenPayload = z.infer<typeof AccessTokenPayloadSchema>; export type AccessTokenPayload = z.infer<typeof AccessTokenPayloadSchema>;
// =============================================================
// Session view (GET /pac/v1/auth/session)
// =============================================================
//
// 前端的「会话视图」—— JWT 只装通用 orgScope(设计 B),前端展不开,故由后端在本端点
// 把 orgScope 展开成内部过滤维(clinicIds/sourceUnits)一并返回。
// 角色/权限/字典前端仍可即时 decodeJwt 拿到;clinicIds/sourceUnits 走本端点补齐。
export const SessionResponseSchema = z.object({
sub: z.string(),
hostId: z.string(),
tenantId: z.string(),
role: UserRoleSchema,
permissions: z.array(z.string()),
/// 通用数据权限(= JWT.orgScope,透传,便于前端展示/调试)
orgScope: z.array(z.string()),
/// 展开后的内部过滤维(前端诊所筛选用)
clinicIds: z.array(z.string()),
sourceUnits: z.array(z.string()),
dictionary: TokenDictionarySchema.optional(),
});
export type SessionResponse = z.infer<typeof SessionResponseSchema>;
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