Commit 927021a0 by luoqi

feat(tenant): 纵深防御 — Prisma 扩展按 ALS 上下文自动注入 tenantId/source_unit

- tenant-context.ts:请求级 AsyncLocalStorage(tenantId/sourceUnits)
- tenant-guard.extension.ts:$extends 对 14 张 scoped 表的列表/聚合读查询自动注入
  tenantId(+ source_unit 模型注品牌);findUnique 不注(破坏唯一键,by-id 走显式守卫);
  无 ALS 上下文(系统任务)不注;$queryRaw 不经扩展(召回引擎自带隔离闸)。env
  PAC_TENANT_GUARD=warn 可切观察模式
- prisma.service:构造器 return this.$extends(...) 透明接入(services 不改)
- tenant-scope.interceptor:handler 在 tenantAls.run 内执行(subscribe 包裹)

验证(已删一次性 CLI):注错 tenant→0、限品牌→该品牌数、系统任务不注、findUnique 不破。
关键:Prisma 惰性查询,await 须在 als.run 内 — interceptor 的 handler 正满足。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent fa71191b
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Observable } from 'rxjs';
import { Observable } from 'rxjs';
import type { AccessTokenPayload } from '@pac/types';
import type { TenantScopeContext } from '../decorators/tenant-scope.decorator';
import { tenantAls } from '../tenant-context';
/**
* Pulls host_id / tenant_id / clinic_ids out of the JWT payload and attaches
......@@ -26,6 +27,22 @@ export class TenantScopeInterceptor implements NestInterceptor {
sourceUnits: req.user.sourceUnits ?? [],
userId: req.user.sub,
};
// 纵深防御:把租户上下文存进 ALS,让 Prisma 扩展对漏写 tenantId 的读查询自动兜底。
// subscribe 在 als.run 内触发 → handler 的异步链(await prisma…)继承该上下文。
const alsCtx = {
hostId: req.user.hostId,
tenantId: req.user.tenantId,
sourceUnits: req.user.sourceUnits ?? [],
};
return new Observable((subscriber) => {
tenantAls.run(alsCtx, () => {
next.handle().subscribe({
next: (v) => subscriber.next(v),
error: (e) => subscriber.error(e),
complete: () => subscriber.complete(),
});
});
});
}
return next.handle();
}
......
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* 请求级租户上下文(AsyncLocalStorage)。
*
* 在 tenant-scope.interceptor 里随每个鉴权请求设置;Prisma 扩展(tenant-guard.extension)
* 读它对 scoped 模型的读查询**自动注入 tenantId**(纵深防御:手写 where 漏了也兜得住)。
*
* **系统任务**(cold-import / 重算 CLI / scheduler)不经过 interceptor → 无上下文 →
* 扩展不注入(它们本就需要跨 scope 全量跑)。
*/
export interface TenantContext {
hostId: string;
tenantId: string;
sourceUnits: string[];
}
export const tenantAls = new AsyncLocalStorage<TenantContext>();
/** 取当前请求的租户上下文;无(系统任务 / 未鉴权)→ undefined。 */
export function currentTenant(): TenantContext | undefined {
return tenantAls.getStore();
}
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { tenantGuardExtension } from './tenant-guard.extension';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
......@@ -12,6 +13,10 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
{ level: 'error', emit: 'event' },
],
});
// 纵深防御:$extends 返回扩展客户端,从构造器 return 让 DI 拿到的就是它(透明,
// services 的 this.prisma.patient 不用改)。扩展按请求级 ALS 上下文自动注入 tenantId。
// $extends 不可变,故只能"构造器 return"这一处接入。
return this.$extends(tenantGuardExtension) as unknown as PrismaService;
}
async onModuleInit(): Promise<void> {
......
import { Prisma } from '@prisma/client';
import { Logger } from '@nestjs/common';
import { currentTenant } from '../common/tenant-context';
/**
* tenantGuardExtension — 纵深防御:对 scoped 模型的读查询**自动注入 tenantId**(+ source_unit)。
*
* 仅当请求级租户上下文存在(AsyncLocalStorage,见 tenant-context.ts)时生效;
* 系统任务(cold-import / 重算 / scheduler)无上下文 → 不注入,照常全量跑。
*
* 只拦**列表/聚合类读**(findMany/findFirst/count/aggregate/groupBy):
* - findUnique 不拦 —— 其 where 是唯一选择器,注入 tenantId 会破坏唯一约束;
* by-id 取数由 service 显式守卫(plan/patient 已加 sourceUnit 校验)。
* - 写操作不拦 —— 写路径都在系统任务或显式 service,且写错 tenant 不是泄漏。
* - $queryRaw 不经扩展 —— 召回引擎原生 SQL 自带「①隔离闸」,且是系统任务无上下文。
*
* 注入是 **AND 叠加**:service 已带 tenantId → 幂等;漏了 → 这里兜住。
*/
const logger = new Logger('TenantGuard');
/** 带 tenant_id 的 14 张表(模型名)。 */
const SCOPED_MODELS = new Set<string>([
'Patient',
'PatientTransaction',
'PatientFact',
'PatientReturnVisit',
'PatientRelation',
'Persona',
'PersonaRecomputeLog',
'FollowupPlan',
'PlanExecution',
'PlanGenerationLog',
'PlanScript',
'PlanSummary',
'AgentInvocation',
'SyncLog',
]);
/** 同时带 source_unit 列的模型(品牌粒度过滤可直接打在自身)。 */
const SOURCE_UNIT_MODELS = new Set<string>(['Patient', 'PatientFact', 'PatientReturnVisit']);
const READ_OPS = new Set<string>(['findMany', 'findFirst', 'findFirstOrThrow', 'count', 'aggregate', 'groupBy']);
/** warn = 只告警不改;enforce = 注入(默认)。env PAC_TENANT_GUARD=warn 可切观察模式。 */
const MODE = process.env.PAC_TENANT_GUARD === 'warn' ? 'warn' : 'enforce';
export const tenantGuardExtension = Prisma.defineExtension({
name: 'tenant-guard',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
const ctx = currentTenant();
if (!ctx || !SCOPED_MODELS.has(model) || !READ_OPS.has(operation)) {
return query(args);
}
const a = args as { where?: Record<string, unknown> };
const hasTenant = a.where && 'tenantId' in a.where;
if (!hasTenant) {
if (MODE === 'warn') {
logger.warn(`[guard] ${model}.${operation} 缺 tenantId 过滤(请求上下文 tenant=${ctx.tenantId})— warn 模式不注入`);
} else {
a.where = { ...a.where, tenantId: ctx.tenantId };
}
}
// 品牌粒度:有 source_unit 列的模型 + 用户受限品牌 → 注入(空数组=不限,集团级角色)
if (MODE === 'enforce' && SOURCE_UNIT_MODELS.has(model) && ctx.sourceUnits.length > 0) {
if (!(a.where && 'sourceUnit' in a.where)) {
a.where = { ...a.where, sourceUnit: { in: ctx.sourceUnits } };
}
}
return query(args);
},
},
},
});
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