Commit 2a858612 by luoqi

refactor(auth): org 树从摄入数据派生(零硬编码 id,跨环境自适应)

之前 org-tree 复用 jvs-dw-preset 的硬编码 clinic UUID 建树 —— 把通用隔离路径绑死到
mock 演示数据 + 写死 id(换环境/换 DW 即废)。改为从摄入数据派生:

- OrgTreeService(注入 Prisma):$queryRaw 从 patient_transactions.clinic_id JOIN
  patients.source_unit 派生「品牌→诊所」,按 (hostId,tenantId) 缓存(10min TTL);
  无数据→退化树。对任何 host/环境自适应,零硬编码 id。
- AuthModule 导出 OrgTreeService;tenant-scope 拦截器(from+mergeMap 异步展开,ALS 不变)
  与 mcp-auth 注入它,每请求 expandScope。
- jvs-dw-preset 退回**仅 mockLogin 用**(dev,env 门控):集团/品牌 slug 是稳定逻辑名;
  clinic UUID/中文名仅演示展示,无数据源,换环境降级成 id —— 隔离仍由 DB 派生树保证正确。

验证(DB 派生树):诊所级石琳 sourceUnits=['瑞尔']/2诊所、品牌瑞尔/3、瑞泰/2、集团不限、
REST /plans 仅命中本诊所 —— 与原硬编码结果一致。tsc 0 错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 7fd4c0d8
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs'; import { from, Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
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'; import { OrgTreeService } from '../../modules/auth/org-tree';
/** /**
* Pulls host_id / tenant_id / clinic_ids out of the JWT payload and attaches * 从 JWT 取 host/tenant/orgScope,展开成 TenantScopeContext 挂到 request,
* a normalized TenantScopeContext to the request, so handlers and services can * handler/service 经 @TenantScope() 取用,无需重读 token。
* grab it via the @TenantScope() decorator without re-reading the token.
* *
* The interceptor never invents a scope: if the request is unauthenticated * 设计 B:JWT 装通用 orgScope,这里按 host org 树(从摄入数据派生)展开成内部过滤维。
* (public routes), it leaves the request untouched. * 展开需查/缓存树 → 异步;用 from()+mergeMap() 串进 Observable,ALS 仍在 handler 执行处生效。
*
* 不臆造 scope:未鉴权(public 路由)原样放行。
*/ */
@Injectable() @Injectable()
export class TenantScopeInterceptor implements NestInterceptor { export class TenantScopeInterceptor implements NestInterceptor {
constructor(private readonly orgTree: OrgTreeService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<{ const req = context.switchToHttp().getRequest<{
user?: AccessTokenPayload; user?: AccessTokenPayload;
tenantScope?: TenantScopeContext; tenantScope?: TenantScopeContext;
}>(); }>();
if (req.user) { const user = req.user;
// 设计 B:JWT 装通用 orgScope,这里按 host org 树展开成内部过滤维(sourceUnits + clinicIds)。 if (!user) return next.handle();
const { sourceUnits, clinicIds } = expandScope(req.user.tenantId, req.user.orgScope ?? []);
return from(this.orgTree.expandScope(user.hostId, user.tenantId, user.orgScope ?? [])).pipe(
mergeMap(({ sourceUnits, clinicIds }) => {
req.tenantScope = { req.tenantScope = {
hostId: req.user.hostId, hostId: user.hostId,
tenantId: req.user.tenantId, tenantId: user.tenantId,
clinicIds, clinicIds,
sourceUnits, sourceUnits,
userId: req.user.sub, userId: user.sub,
}; };
// 纵深防御:把租户上下文存进 ALS,让 Prisma 扩展对漏写 tenantId 的读查询自动兜底。 // 纵深防御:把租户上下文存进 ALS,让 Prisma 扩展对漏写 tenantId 的读查询自动兜底。
// subscribe 在 als.run 内触发 → handler 的异步链(await prisma…)继承该上下文。 // subscribe 在 als.run 内触发 → handler 的异步链(await prisma…)继承该上下文。
const alsCtx = { const alsCtx = { hostId: user.hostId, tenantId: user.tenantId, sourceUnits };
hostId: req.user.hostId,
tenantId: req.user.tenantId,
sourceUnits,
};
return new Observable((subscriber) => { return new Observable((subscriber) => {
tenantAls.run(alsCtx, () => { tenantAls.run(alsCtx, () => {
next.handle().subscribe({ next.handle().subscribe({
...@@ -46,7 +48,7 @@ export class TenantScopeInterceptor implements NestInterceptor { ...@@ -46,7 +48,7 @@ export class TenantScopeInterceptor implements NestInterceptor {
}); });
}); });
}); });
} }),
return next.handle(); );
} }
} }
...@@ -6,6 +6,7 @@ import { parseDurationToSeconds } from './duration'; ...@@ -6,6 +6,7 @@ import { parseDurationToSeconds } from './duration';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { OrgTreeService } from './org-tree';
@Module({ @Module({
imports: [ imports: [
...@@ -22,7 +23,7 @@ import { JwtStrategy } from './jwt.strategy'; ...@@ -22,7 +23,7 @@ import { JwtStrategy } from './jwt.strategy';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [AuthService, JwtStrategy, OrgTreeService],
exports: [AuthService, JwtModule], exports: [AuthService, JwtModule, OrgTreeService],
}) })
export class AuthModule {} export class AuthModule {}
/** /**
* jvs-dw 预制组织数据(集团模型:tenant=集团 slug,品牌=source_unit,诊所=UUID)。 * jvs-dw **模拟登录**预制数据(dev / 试部署专用,env 门控,生产关)。
* *
* 单一数据源,两处用: * ⚠️ 仅 mockLogin 用,**不是通用机制、不进生产隔离路径**:
* - auth.service mockLogin:派生 orgScope / 诊所字典 / 品牌 slug; * - 集团/品牌 slug('ruier-grp' / '瑞尔' / '瑞泰')是稳定逻辑名,跨环境一致;
* - org-tree.ts:派生该 host 的 org 树(集团→品牌→诊所),供请求时 expandOrgScope。 * - clinic UUID + 中文名仅作 mock 演示展示(dictionary)。无数据来源的诊所名只能在此内置;
* 换环境(指到别的 DW)若 id 不符,隔离仍正确(见下),只是演示名回退成 id。
* *
* 来源:`data/jvs-dw/manifest.yaml` §"集团模型" + `docs/deployment-data-ingest.md` §clinic UUID。 * 生产隔离路径的 org 树**不读这里** —— 由 org-tree.ts 从摄入数据(patient_transactions
* 通用态本应从摄入数据派生(clinic→brand);此处先内置 jvs-dw * 的 clinic_id + source_unit)派生,零硬编码、env 自适应。别的 host 走真 SSO,没有 preset
*/ */
export const JVS_DW_PRESETS = { export const JVS_DW_PRESETS = {
ruier: { ruier: {
......
/** import { Injectable } from '@nestjs/common';
* host org 树解析 + scope 展开(纯函数,无 DI)。 import { PrismaService } from '../../prisma/prisma.service';
*
* 设计 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 { import {
buildOrgTree, buildOrgTree,
buildTree, buildTree,
...@@ -18,20 +8,57 @@ import { ...@@ -18,20 +8,57 @@ import {
type OrgTree, type OrgTree,
} from './org-scope'; } from './org-scope';
/** tenantId → org 树。jvs-dw 内置;其余按需(通用态从摄入数据派生)。 */ /**
const TREES: Record<string, OrgTree> = { * host org 树解析 + scope 展开 —— **从摄入数据派生,零硬编码**。
[JVS_DW_PRESETS.ruier.tenantId]: buildOrgTree(JVS_DW_PRESETS.ruier.tenantId, { *
[JVS_DW_PRESETS.ruier.sourceUnit]: Object.keys(JVS_DW_PRESETS.ruier.clinics), * 设计 B:JWT 只装通用 orgScope;**每个请求**在 tenant-scope 拦截器 / MCP 鉴权处调
[JVS_DW_PRESETS.ruitai.sourceUnit]: Object.keys(JVS_DW_PRESETS.ruitai.clinics), * expandScope() 按 host org 树展开成内部过滤维(sourceUnits + clinicIds)。
}), *
}; * 树不写死任何 id —— 从 `patient_transactions`(立柱 clinic_id + source_unit)派生
* 「品牌 → 诊所」映射,对**任何 host、任何环境**自适应(指到别的 DW 自动跟着变)。
* 按 (hostId, tenantId) 缓存(TTL);数据日更,够鲜。无数据 → 退化树(根=tenantId,无子)。
*/
@Injectable()
export class OrgTreeService {
constructor(private readonly prisma: PrismaService) {}
/** 取 host org 树;未知 tenantId → 退化树(根=tenantId,无子)。 */ private readonly cache = new Map<string, { tree: OrgTree; at: number }>();
export function getOrgTree(tenantId: string): OrgTree { private readonly ttlMs = 10 * 60 * 1000; // 10min
return TREES[tenantId] ?? buildTree({ id: tenantId });
} /** 取 host org 树(缓存;miss 时从摄入数据派生)。 */
async getTree(hostId: string, tenantId: string): Promise<OrgTree> {
const key = `${hostId}:${tenantId}`;
const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < this.ttlMs) return hit.tree;
// 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」:
// clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。
// 显式 host+tenant 过滤(此处在 ALS 之前跑,纵深防御扩展不兜,自带条件)。
const rows = await this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>`
SELECT DISTINCT p.source_unit, pt.clinic_id
FROM patient_transactions pt
JOIN patients p ON p.id = pt.patient_id
WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId}
AND p.source_unit <> '' AND pt.clinic_id <> ''
`;
const brandClinics: Record<string, string[]> = {};
for (const r of rows) {
(brandClinics[r.source_unit] ??= []).push(r.clinic_id);
}
const tree =
Object.keys(brandClinics).length > 0
? buildOrgTree(tenantId, brandClinics)
: buildTree({ id: tenantId }); // 无数据 → 退化树
this.cache.set(key, { tree, at: Date.now() });
return tree;
}
/** 把 orgScope(任意层级 org 节点)展开成内部过滤维 { sourceUnits, clinicIds }。 */ /** 把 orgScope(任意层级 org 节点)展开成内部过滤维 { sourceUnits, clinicIds }。 */
export function expandScope(tenantId: string, orgScope: string[]): ExpandedScope { async expandScope(
return expandOrgScope(getOrgTree(tenantId), orgScope); hostId: string,
tenantId: string,
orgScope: string[],
): Promise<ExpandedScope> {
return expandOrgScope(await this.getTree(hostId, tenantId), orgScope);
}
} }
...@@ -2,7 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; ...@@ -2,7 +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'; import { OrgTreeService } from '../auth/org-tree';
export interface McpAuthContext { export interface McpAuthContext {
scope: TenantScopeContext; scope: TenantScopeContext;
...@@ -17,7 +17,10 @@ export interface McpAuthContext { ...@@ -17,7 +17,10 @@ export interface McpAuthContext {
*/ */
@Injectable() @Injectable()
export class McpAuthService { export class McpAuthService {
constructor(private readonly jwt: JwtService) {} constructor(
private readonly jwt: JwtService,
private readonly orgTree: OrgTreeService,
) {}
async verifyBearer(authHeader: string | undefined): Promise<McpAuthContext> { async verifyBearer(authHeader: string | undefined): Promise<McpAuthContext> {
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
...@@ -33,8 +36,12 @@ export class McpAuthService { ...@@ -33,8 +36,12 @@ 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 拦截器同一套)。 // 设计 B:JWT 装通用 orgScope,这里展开成内部过滤维(与 REST 拦截器同一套,树从摄入数据派生)。
const { sourceUnits, clinicIds } = expandScope(payload.tenantId, payload.orgScope ?? []); const { sourceUnits, clinicIds } = await this.orgTree.expandScope(
payload.hostId,
payload.tenantId,
payload.orgScope ?? [],
);
return { return {
scope: { scope: {
hostId: payload.hostId, hostId: payload.hostId,
......
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