Commit a6d7eb22 by luoqi

fix(tenant): agent 审查发现的 4 个 HIGH

① source_event_id 品牌盲 → 数据丢失(HIGH):synthesizer 的幂等键
   ${ctx}:${resource}:${subjectId}:${updatedAt} 不含品牌,两品牌同 subjectId+
   updatedAt 撞键被去重跳过 → 丢 transaction/fact。并进 source_unit(re-import 仍幂等)。
② persona by-id 跨品牌泄漏(HIGH):/patients/:id/persona 的 getCurrent 无 scope 过滤
   (Persona 非 source_unit 模型,纵深防御只注 tenantId 不注品牌)。加经 patient 关系的
   sourceUnit 过滤;controller + 2 处 MCP 调用传 scope。
③ MCP by-id 泄漏(HIGH):MCP @Public 无 ALS 上下文,纵深防御不兜;
   assertPatientInScope / activeRecallPlan 只按 host+tenant,补 sourceUnit 过滤。
④ exchangeToken 真 host SSO 丢 sourceUnits(latent):TokenExchangeUserSchema 加
   source_units + exchangeToken 透传(否则真 host 登录 = 不限品牌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b16f58b0
......@@ -85,6 +85,7 @@ export class AuthService {
hostId: host.id,
tenantId,
clinicIds,
sourceUnits: req.user.sourceUnits ?? [], // 集团模型:品牌数据范围(缺省=不限)
role: req.user.role,
permissions,
// 可选名字典(clinics/users id→名)透传进 JWT,前端用于 UI 显示
......@@ -96,6 +97,7 @@ export class AuthService {
hostId: host.id,
tenantId,
clinicIds,
sourceUnits: req.user.sourceUnits ?? [],
role: req.user.role,
dictionary: req.user.dictionary,
});
......
......@@ -99,7 +99,7 @@ export class McpServerFactory {
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
const [persona, recallPlan, timeline] = await Promise.all([
this.persona.getCurrent(patientId),
this.persona.getCurrent(scope, patientId),
this.activeRecallPlan(scope, patientId),
this.patient.getTimeline(scope, patientId, 20),
]);
......@@ -121,7 +121,7 @@ export class McpServerFactory {
},
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
return jsonResult(await this.persona.getCurrent(patientId));
return jsonResult(await this.persona.getCurrent(scope, patientId));
},
);
......@@ -235,7 +235,13 @@ export class McpServerFactory {
/** 越权防护:确认该 patientId 属当前租户(persona.getCurrent 等不带 scope 的读之前必调)。 */
private async assertPatientInScope(scope: TenantScopeContext, patientId: string): Promise<void> {
const p = await this.prisma.patient.findFirst({
where: { id: patientId, hostId: scope.hostId, tenantId: scope.tenantId },
// MCP 走 @Public 无 ALS 上下文 → 纵深防御不兜,品牌隔离全靠这里显式过滤
where: {
id: patientId,
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
},
select: { id: true },
});
if (!p) throw new Error(`patient ${patientId} 不在当前租户范围内`);
......@@ -247,6 +253,7 @@ export class McpServerFactory {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
status: { in: ['active', 'assigned'] },
supersededAt: null,
},
......
......@@ -21,10 +21,10 @@ export class PersonaController {
@RequirePermission(Permission.PERSONA_VIEW)
@ApiOperation({ summary: 'Current (non-superseded) persona for the patient' })
async getCurrent(
@TenantScope() _scope: TenantScopeContext,
@TenantScope() scope: TenantScopeContext,
@Param('patientId') patientId: string,
) {
const persona = await this.persona.getCurrent(patientId);
const persona = await this.persona.getCurrent(scope, patientId);
return { persona };
}
}
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { FeatureRegistry } from './features/feature.registry';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import type {
......@@ -359,9 +360,15 @@ export class PersonaService {
}
}
async getCurrent(patientId: string) {
async getCurrent(scope: TenantScopeContext, patientId: string) {
const p = await this.prisma.persona.findFirst({
where: { patientId, supersededAt: null },
// 品牌隔离:persona 无 source_unit 列,经 patient 关系圈品牌(空=不限)。
// tenantId(集团)由 Prisma 纵深防御扩展自动注入。
where: {
patientId,
supersededAt: null,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
},
include: { features: true },
orderBy: { version: 'desc' },
});
......
......@@ -1625,6 +1625,7 @@ export class ColdImportService {
tenantId,
patientIdResolver: (extId) => patientIndex!.get(patientIndexKey(sourceUnit, extId)),
sourceContext,
sourceUnit,
});
if (!txn) {
stats.failed++;
......
......@@ -129,6 +129,7 @@ export class PipelineDispatcher {
tenantId: input.tenantId,
patientIdResolver: (extId) => patientIndex.get(patientIndexKey(sourceUnit, extId)),
sourceContext: input.sourceContext,
sourceUnit,
});
if (!txn) {
m.failed++;
......
......@@ -45,6 +45,8 @@ export class TransactionSynthesizer {
tenantId: string;
patientIdResolver: (externalId: string) => string | undefined;
sourceContext: string;
/** 患者命名空间(集团模型):并进 sourceEventId,避免两品牌同 subjectId+updatedAt 撞键被去重。 */
sourceUnit: string;
}): Prisma.PatientTransactionUncheckedCreateInput | null {
const { emits } = input;
if (!emits) {
......@@ -109,7 +111,8 @@ export class TransactionSynthesizer {
// 冷启同 row + 同 updatedAt 视为同一事件(re-import 时 idempotent)
const updatedAtMark =
(c.updatedAt as string | undefined) ?? (c.createdAt as string | undefined) ?? '';
const sourceEventId = `${input.sourceContext}:${input.resource}:${subjectId}:${updatedAtMark}`;
// source_unit 并进幂等键:集团内同 subjectId 跨品牌(瑞尔/瑞泰 encounter 1011032)是两条不同事件
const sourceEventId = `${input.sourceContext}:${input.resource}:${input.sourceUnit}:${subjectId}:${updatedAtMark}`;
const payloadHash = this.sha256(JSON.stringify(input.rawRow));
......
......@@ -35,6 +35,10 @@ export const TokenExchangeUserSchema = z
clinicIds: z
.array(z.string())
.describe('Clinic ids the user is authorized for'),
sourceUnits: z
.array(z.string())
.optional()
.describe('集团模型:用户可见品牌(source_unit)。空/缺省 = 不限品牌(集团级角色)'),
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