Commit a0f846f2 by luoqi

fix(越权): 三个读接口不校验 clinicId —— 主管能看到别家诊所的名册/负载/池子矩阵

产品在测试机上发现工作台发了两个 workload 请求、clinicId 不一样。
查下去不只是"多发一个",是**那一发真的取回了别家的数据**。

── 服务端(真问题)──────────────────────────────────────────
`/plans/assignments/agents`、`/plans/assignments/workload`、`/plans/matrix`
三个读接口拿着查询串里的 clinicId 直接查,不校验是否在 scope 内。
实测:北京朝阳公园的主管(clinicIds=[66701845…])带杭州大厦的 id 请求,
拿到 200 + 那家 26 位客服的**姓名与在手负载**;矩阵同样能拿到完整患者量分布。

️ 写路径一直是拦的(create 里那句 includes 判断),所以分不走别家的人 ——
   但名册是员工姓名、矩阵是患者量分布,读一样不能敞。

闸抽成 common/decorators/resolve-clinic-id:不传取第一个诊所,传了必须在范围内,
范围外抛 Forbidden(10107)并列出真实 id —— 不能"当成这个诊所没人"返回 0,
0 是合法答案,静默返回会让助手拿着 0 去解释"为什么这批人是空的"(违 T14)。
集团级(clinicIds 为空)原样放行, 别把空数组当成没权限。

MCP 里原来抄了一份一模一样的实现 —— 抄一份的直接后果就是补的时候只补了一边。
现在两边共用一份。

── 前端(触发源)────────────────────────────────────────────
第一帧 user.clinicIds 还是 undefined(JWT 里没有这一项,只能等 /auth/session),
visibleClinics 回落到"字典里的全部诊所" → 锁了第一个「杭州大厦」→ 发出那一发。
session 回来后自己纠正成朝阳公园,所以肉眼只看见"发了两个请求"。

新增 clinicScopeReady():undefined=还没加载 / []=集团级,两者必须分开 ——
 别改 visibleClinics 的回落语义去顺手修,那会让集团级用户的筛选器空掉。
实测改后:进工作台只发 1 个 workload,clinicId 正确。

️ 前端等待与服务端闸是**两层**,缺一不可:查询串是用户可改的。

测试:回归从"grep MCP 源码"改成锁**闸只有一份实现** + **每个收 clinicId 的
REST 读接口都过闸**(按 @Query('clinicId') 出现次数比对),这正是漏掉的那一类。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 15b347e3
Pipeline #3550 failed in 0 seconds
import { ForbiddenException } from '@nestjs/common';
import type { TenantScopeContext } from './tenant-scope.decorator';
/**
* 🔴 **诊所 id 的唯一入口** —— 不传就用登录人的第一个诊所,传了就**必须在他的范围里**。
*
* ── 由来(2026-08-10 测试机实测的越权)──────────────────────────
* 北京朝阳公园的主管(`clinicIds = [66701845…]`)带着别家诊所的 id
* (`7d49539c…` 杭州大厦)请求 `/plans/assignments/workload`,拿到了
* **200 + 那家 26 位客服的姓名与负载**。`/agents`、`/plans/matrix` 同样敞着。
*
* ⚠️ 这三个都是**读**接口。写路径(创建批次)早就拦了
* (`plan-assignment.service` 里那句 `!scope.clinicIds.includes(dto.clinicId)`),
* 所以分不走别家的人 —— 但名册、负载、整个池子矩阵能随便看。
* ⛔ 别因为"写拦住了"就觉得读无所谓:名册是**员工姓名**,矩阵是**患者量分布**。
*
* ⚠️ 触发它的不是攻击,是**前端第一帧**:`visibleClinics` 在 `/auth/session` 回来之前
* 会回落到"字典里的全部诊所",于是工作台第一帧就带着别家 id 发了一次请求。
* 前端那边也修了,但 ⛔ **前端修好不等于这里可以不拦** ——
* 查询串是用户可改的,少了服务端这道闸,改个 URL 就越权。
*
* ⚠️ `scope.clinicIds` 为空 = **集团级范围**(看全部诊所),此时无从校验,原样放行。
* ⛔ 别把空数组当成"没有权限"去拒绝 —— 那会把集团角色全锁死。
*
* ⚠️ 抛 `ForbiddenException`(→ 10107),⛔ 不能"当成这个诊所没人"返回空:
* 0 是一个合法答案,静默返回空会让调用方(尤其是助手)拿着 0 去解释
* "为什么这批人是空的",越解释越像真的(违 T14「口径对数」)。
*/
export function resolveClinicId(scope: TenantScopeContext, clinicId?: string): string {
const given = clinicId?.trim();
if (!given) {
const fallback = scope.clinicIds[0];
if (!fallback) {
throw new ForbiddenException(
'没有指定诊所,当前登录人也没有绑定诊所 —— 请先确认数据范围,⛔ 不要自己编一个诊所 id。',
);
}
return fallback;
}
if (scope.clinicIds.length && !scope.clinicIds.includes(given)) {
throw new ForbiddenException(
`诊所 id「${given}」不在当前登录人的数据范围内(他的诊所是:${scope.clinicIds.join(' / ')})。`,
);
}
return given;
}
...@@ -17,6 +17,7 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service'; ...@@ -17,6 +17,7 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attributes.service'; import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attributes.service';
import type { ListPlansQueryDto } from '../plan/dto/plan.dto'; import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
...@@ -25,44 +26,16 @@ function jsonResult(data: unknown) { ...@@ -25,44 +26,16 @@ function jsonResult(data: unknown) {
} }
/** /**
* 🔴 诊所 id 的**唯一**入口 —— 不传就用登录人的第一个诊所,传了就**必须在他的范围里** * 诊所 id 的解析与越权闸 —— **复用 REST 那一份**(`common/decorators/resolve-clinic-id`)
* *
* ── 由来(2026-08-06 实测)──────────────────────────────────────── * ⚠️ 这里原来抄了一份一模一样的实现。2026-08-10 发现 `/agents`、`/workload`、
* 主管点了矩阵的「充填 · 2–3 年」(138 人)再说「我只需要男性患者」,助手回 * `/plans/matrix` 三个 REST 读接口**根本没拦**(朝阳公园的主管带别家 id
* 「这个格子当前是空的(0 人)」,还顺带编了两条像模像样的原因。 * 拿到了那家 26 人的姓名与负载)—— 抄一份的直接后果就是:补的时候只补了一边。
* 真因:`get_cohort_attributes` 的 `clinicId` 是**必填且没有兜底**,模型只好自己编了一个 * ⛔ 别再复制:两份必然漂,而漂了不报错。
* `"CL001"` 传进来 —— 查询完全正确,只是 `target_clinic_id = 'CL001'` 一条都匹配不上。
* *
* ⚠️ **这个 bug 最坏的地方不是查不到,是查不到看起来像个结论**: * ⚠️ 抛的是 `ForbiddenException`,MCP 这边照样能把 message 带给模型(工具错误即文本),
* 0 是一个合法答案,模型拿到 0 就去解释"为什么这批人是空的",越解释越像真的。 * 所以「⛔ 不要自己编诊所 id」那半句提示挪进了下面 CLINIC_ID_SCHEMA 的 describe。
* 主管刚在矩阵上看过 138,下一句就成了 0 —— 违 T14「口径对数」。
*
* ✅ 两道闸,缺一不可:
* ① 不传 → 用 `scope.clinicIds[0]`(与 propose_assignment 的兜底完全一致,⛔ 别两套)
* ② 传了但不在范围里 → **抛错并把真实 id 列出来**,⛔ 绝不能当成"这个诊所没人"返回 0
*
* ⚠️ `scope.clinicIds` 为空 = 集团级范围(看全部诊所),此时无从校验,原样放行 ——
* 那种 scope 下 poolBaseSql 仍会按传入 id 过滤,查不到就是真查不到。
*/ */
function resolveClinicId(scope: TenantScopeContext, clinicId?: string): string {
const given = clinicId?.trim();
if (!given) {
const fallback = scope.clinicIds[0];
if (!fallback) {
throw new Error(
'没有指定诊所,当前登录人也没有绑定诊所 —— 请先用 get_current_user 确认数据范围,⛔ 不要自己编一个诊所 id。',
);
}
return fallback;
}
if (scope.clinicIds.length && !scope.clinicIds.includes(given)) {
throw new Error(
`诊所 id「${given}」不在当前登录人的数据范围内(他的诊所是:${scope.clinicIds.join(' / ')})。` +
'⛔ 不要自己编诊所 id —— 用 get_current_user 返回的 clinicIds,或者干脆不传(默认取第一个)。',
);
}
return given;
}
/** /**
* 诊所 id 入参 —— **可选**,且明说"不知道就别传"。 * 诊所 id 入参 —— **可选**,且明说"不知道就别传"。
......
...@@ -5,6 +5,7 @@ import { Permission } from '@pac/types'; ...@@ -5,6 +5,7 @@ import { Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service'; import { PlanAssignmentService } from './plan-assignment.service';
...@@ -65,7 +66,8 @@ export class AssignmentController { ...@@ -65,7 +66,8 @@ export class AssignmentController {
@Query('months') months?: string, @Query('months') months?: string,
@Query('include') include?: string, @Query('include') include?: string,
) { ) {
return this.roster.list(scope, clinicId, { // 🔴 越权闸:名册是**员工姓名**。⛔ 别只信前端传对 —— 查询串是用户可改的(见 resolveClinicId)
return this.roster.list(scope, resolveClinicId(scope, clinicId), {
months: months ? Number(months) : undefined, months: months ? Number(months) : undefined,
// 主管显式点名的人即使不在名册也要能查到 —— 名册是建议不是白名单 // 主管显式点名的人即使不在名册也要能查到 —— 名册是建议不是白名单
extraUserIds: include ? include.split(',').filter(Boolean) : undefined, extraUserIds: include ? include.split(',').filter(Boolean) : undefined,
...@@ -89,7 +91,8 @@ export class AssignmentController { ...@@ -89,7 +91,8 @@ export class AssignmentController {
@Query('clinicId') clinicId: string, @Query('clinicId') clinicId: string,
@Query('days') days?: string, @Query('days') days?: string,
) { ) {
return this.assignments.workload(scope, clinicId, days ? Number(days) : 7); // 🔴 越权闸 —— 2026-08-10 实测:朝阳公园的主管带别家 id 请求,拿到了那家 26 人的姓名与负载
return this.assignments.workload(scope, resolveClinicId(scope, clinicId), days ? Number(days) : 7);
} }
@Post() @Post()
......
...@@ -19,6 +19,7 @@ import { ...@@ -19,6 +19,7 @@ import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { import {
AssignPlanRequestDto, AssignPlanRequestDto,
ListPlansQueryDto, ListPlansQueryDto,
...@@ -110,10 +111,12 @@ export class PlanController { ...@@ -110,10 +111,12 @@ export class PlanController {
'温度定义见 packages/types/temperature.ts:逐条 gap 用自己 K 码的窗口判档、取最热。', '温度定义见 packages/types/temperature.ts:逐条 gap 用自己 K 码的窗口判档、取最热。',
}) })
matrix(@TenantScope() scope: TenantScopeContext, @Query('clinicId') clinicId?: string) { matrix(@TenantScope() scope: TenantScopeContext, @Query('clinicId') clinicId?: string) {
// 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的 /**
const cid = clinicId ?? scope.clinicIds[0]; * 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
if (!cid) throw new BadRequestException('当前登录人没有绑定诊所,无法出矩阵'); * 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
return this.cohorts.matrix(scope, cid); * 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。
*/
return this.cohorts.matrix(scope, resolveClinicId(scope, clinicId));
} }
@Get(':id') @Get(':id')
......
...@@ -44,9 +44,49 @@ describe('MCP 诊所 id —— 不许模型自己编', () => { ...@@ -44,9 +44,49 @@ describe('MCP 诊所 id —— 不许模型自己编', () => {
// 出现几次 clinicId 作为查询入参,就该有几次 resolveClinicId // 出现几次 clinicId 作为查询入参,就该有几次 resolveClinicId
const guarded = FACTORY.match(/resolveClinicId\(scope,/g) ?? []; const guarded = FACTORY.match(/resolveClinicId\(scope,/g) ?? [];
expect(guarded.length).toBeGreaterThanOrEqual(4); expect(guarded.length).toBeGreaterThanOrEqual(4);
});
/**
* 🔴🔴 **闸只能有一份实现,且 REST 与 MCP 都要走它**(2026-08-10 测试机实测的越权)。
*
* 原来 MCP 里抄了一份 `resolveClinicId`。抄一份的直接后果是**补的时候只补了一边**:
* 北京朝阳公园的主管带着杭州大厦的 id 请求 `/plans/assignments/workload`,
* 拿到了 **200 + 那家 26 位客服的姓名与负载**;`/agents`、`/plans/matrix` 同样敞着。
*
* ⚠️ 触发它的不是攻击,是**前端第一帧**(session 没回来时回落到"全部诊所")——
* 前端也修了,但 ⛔ 查询串是用户可改的,服务端这道闸不能省。
*/
const GUARD = readFileSync(
join(__dirname, '../src/common/decorators/resolve-clinic-id.ts'),
'utf8',
);
test('🔴🔴 闸只有一份实现 —— MCP 不许再抄一份', () => {
// 范围外必须**抛错**并列出真实 id —— 静默返回 0 是这个 bug 的全部危害 // 范围外必须**抛错**并列出真实 id —— 静默返回 0 是这个 bug 的全部危害
expect(FACTORY).toMatch(/不在当前登录人的数据范围内/); expect(GUARD).toMatch(/不在当前登录人的数据范围内/);
expect(FACTORY).toMatch(/scope\.clinicIds\.join/); expect(GUARD).toMatch(/scope\.clinicIds\.join/);
expect(GUARD).toMatch(/ForbiddenException/);
// 集团级(clinicIds 为空)无从校验,必须放行 —— ⛔ 别把空数组当成"没权限"
expect(GUARD).toMatch(/scope\.clinicIds\.length &&/);
// ⛔ MCP 只许 import,不许自己再定义一个
expect(FACTORY).toMatch(/import \{ resolveClinicId \} from/);
expect(FACTORY).not.toMatch(/function resolveClinicId/);
});
test.each([
['assignment.controller.ts', 'agents / workload'],
['plan.controller.ts', 'matrix'],
])('🔴🔴 REST 读接口 %s(%s)也必须过闸', (file) => {
const src = readFileSync(join(__dirname, `../src/modules/plan/${file}`), 'utf8');
// 每个收 clinicId 查询参数的读接口,都要有一次 resolveClinicId
const takes = (src.match(/@Query\('clinicId'\)/g) ?? []).length;
const guards = (src.match(/resolveClinicId\(scope,/g) ?? []).length;
expect(takes).toBeGreaterThan(0);
expect(guards).toBeGreaterThanOrEqual(takes);
// ⛔ 不许再把裸 clinicId 直接丢进 service(那正是漏的写法)
expect(src).not.toMatch(/\.list\(scope, clinicId,/);
expect(src).not.toMatch(/\.workload\(scope, clinicId,/);
expect(src).not.toMatch(/\.matrix\(scope, clinicId\)/);
}); });
test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => { test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => {
......
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore, visibleClinics } from '@/stores/auth-store'; import { clinicScopeReady, useAuthStore, visibleClinics } from '@/stores/auth-store';
import { AssistantWidget } from '@/components/assistant/assistant-widget'; import { AssistantWidget } from '@/components/assistant/assistant-widget';
import { SupervisorHeader } from './supervisor-header'; import { SupervisorHeader } from './supervisor-header';
import { BatchTracking } from './batch-tracking'; import { BatchTracking } from './batch-tracking';
...@@ -28,19 +28,25 @@ export function SupervisorWorkbench() { ...@@ -28,19 +28,25 @@ export function SupervisorWorkbench() {
/** /**
* 默认落到第一个可见诊所。 * 默认落到第一个可见诊所。
* *
* 🔴 **必须校验"当前选的还在不在列表里"**,⛔ 不能只判 `clinicId == null` * 🔴 **必须等 `/auth/session` 回来**(`clinicScopeReady`),⛔ 不能一挂载就锁。
* (2026-08-07 实测踩过):`visibleClinics` 在 session 加载**之前**会回落到 * 2026-08-10 测试机实测:第一帧 `user.clinicIds` 还是 undefined,
* 「字典里的全部诊所」(`user.clinicIds` 还是空的),于是第一帧就把一个 * `visibleClinics` 回落到「字典里的全部诊所」,于是锁了第一个「杭州大厦」,
* **别家诊所**锁进了 state;session 回来后列表缩成他自己那一家, * **带着别家诊所 id 真的发出了一次 `/workload`** —— 而那时服务端还没有越权闸,
* 而 clinicId 已经指向别处 —— 界面表现是:头部显示「选择诊所」, * 那一发把别家 26 位客服的姓名与负载取了回来。
* session 回来后这里自己纠正成朝阳公园,所以肉眼只看见"发了两个请求"。
*
* 🔴 **同时必须校验"当前选的还在不在列表里"**(2026-08-07 先踩的那一半):
* 否则纠正不会发生 —— 界面表现是头部显示「选择诊所」,
* 左边批次是自己的、右边团队是别家的人,**两栏对不上却都不报错**。 * 左边批次是自己的、右边团队是别家的人,**两栏对不上却都不报错**。
* ⚠️ 只在"不在列表里"时才纠正 —— 用户手动切过的选择不能被后续 render 冲掉。 * ⚠️ 只在"不在列表里"时才纠正 —— 用户手动切过的选择不能被后续 render 冲掉。
* ⚠️ 服务端 `resolveClinicId` 是独立的第二层闸,⛔ 别因为这里等了就把它省掉。
*/ */
const scopeReady = clinicScopeReady(user);
useEffect(() => { useEffect(() => {
if (clinics.length === 0) return; if (!scopeReady || clinics.length === 0) return;
if (clinicId != null && clinics.some((c) => c.id === clinicId)) return; if (clinicId != null && clinics.some((c) => c.id === clinicId)) return;
setClinicId(clinics[0]!.id); setClinicId(clinics[0]!.id);
}, [clinics, clinicId]); }, [scopeReady, clinics, clinicId]);
/** /**
* 「分一批新的」两版并存(设计稿 `newBatchMode`,产品要求都实现)。 * 「分一批新的」两版并存(设计稿 `newBatchMode`,产品要求都实现)。
......
...@@ -27,6 +27,28 @@ export function visibleClinics(user: SessionUser | null): { id: string; name: st ...@@ -27,6 +27,28 @@ export function visibleClinics(user: SessionUser | null): { id: string; name: st
return ids.map((id) => ({ id, name: String(dict[id] ?? id) })); return ids.map((id) => ({ id, name: String(dict[id] ?? id) }));
} }
/**
* 🔴 **诊所范围到底加载出来了没有**。
*
* ⚠️ `visibleClinics` 分不出这两种情况,它俩都会回落到"字典里的全部诊所":
* · `clinicIds === undefined` —— `/auth/session` **还没回来**(JWT 里根本没有这一项)
* · `clinicIds === []` —— 集团级,**真的**不限诊所
* 对筛选器来说回落是对的(集团级不该空白),但对"**默认选中哪一家**"是致命的。
*
* 🔴 2026-08-10 测试机实测的后果:朝阳公园的主管打开工作台,第一帧 `clinicIds` 还是
* undefined → 回落到全部诊所 → 锁了第一个「杭州大厦」→ **带着别家 id 发了一次
* `/workload`**,而那时服务端还没有越权闸,那一发**真的把别家 26 位客服的姓名与负载
* 取回来了**。session 回来后界面自己纠正成朝阳公园,所以肉眼只看见"发了两个请求"。
*
* ⇒ 凡是要拿某一家诊所**去发请求**的地方,先等这个为 true。
* ⛔ 别改 `visibleClinics` 的回落语义去"顺便修掉" —— 那会让集团级用户的筛选器空掉。
* ⚠️ 服务端那道闸(`resolveClinicId`)是**独立**的第二层,⛔ 不能因为这里等了就把它省掉:
* 查询串是用户可改的。
*/
export function clinicScopeReady(user: SessionUser | null): boolean {
return Array.isArray(user?.clinicIds);
}
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
......
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