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