Commit 67668e9e by luoqi

fix(plan): orgScope→clinicIds 作硬边界,view_all 不再豁免越权(修 leader 仍能跨诊所)

上版按 !PLAN_VIEW_ALL 豁免,但 leader 角色本就带 PLAN_VIEW_ALL → 漏洞对 leader 仍在。
clinicIds 由 orgScope 派生,本应是硬边界:list 去掉 view!=='all' 豁免、detail/aggregate 去掉
view_all 豁免,始终按 clinicIds 圈诊所(null 集团池放行)。宽 scope(集团 leader/admin)clinicIds
覆盖全部 → no-op 不受影响;窄 scope 才被真正圈住。view_all 语义=本 scope 内看所有经办人,不跨诊所。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent c4bb0a94
Pipeline #3379 failed in 0 seconds
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode, Permission } from '@pac/types'; import { applyLiveDays, ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error'; import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
...@@ -27,11 +27,7 @@ export class PlanAggregateService { ...@@ -27,11 +27,7 @@ export class PlanAggregateService {
* 按 planId 查 → 反查 patient → 同 assemble。 * 按 planId 查 → 反查 patient → 同 assemble。
* (app)/plans/[planId] 路由 + PlansAggregateController 的入口。 * (app)/plans/[planId] 路由 + PlansAggregateController 的入口。
*/ */
async getPlanDetailByPlanId( async getPlanDetailByPlanId(scope: TenantScopeContext, planId: string) {
scope: TenantScopeContext,
planId: string,
permissions: readonly string[],
) {
const plan = await this.prisma.followupPlan.findFirst({ const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit) // 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit)
where: { where: {
...@@ -44,15 +40,11 @@ export class PlanAggregateService { ...@@ -44,15 +40,11 @@ export class PlanAggregateService {
if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`); throw new NotFoundException(`Plan ${planId} not found`);
} }
// clinic 隔离:与召回池列表一致 —— 无 PLAN_VIEW_ALL 时,目标诊所须在 orgScope(clinicIds)内, // clinic 隔离:与召回池列表一致 —— orgScope→clinicIds 硬边界,目标诊所须在 clinicIds 内或为
// 或为 null(集团池)。否则按 ID 直取也不可见 —— 修"切诊所后残留旧 URL 越权看到别诊所患者"。 // null(集团池)。否则按 ID 直取也不可见 —— 修"切诊所后残留旧 URL 越权看到别诊所患者"
// (view_all 不豁免:clinicIds 由 orgScope 派生,宽 scope 天然覆盖全部,窄 scope 才被圈住)。
// 抛 PLAN_NOT_FOUND(非普通 404):前端据此 router.push('/plans') → landing 选回当前 scope 首个患者。 // 抛 PLAN_NOT_FOUND(非普通 404):前端据此 router.push('/plans') → landing 选回当前 scope 首个患者。
if ( if (scope.clinicIds.length > 0 && plan.targetClinicId && !scope.clinicIds.includes(plan.targetClinicId)) {
!permissions.includes(Permission.PLAN_VIEW_ALL) &&
scope.clinicIds.length > 0 &&
plan.targetClinicId &&
!scope.clinicIds.includes(plan.targetClinicId)
) {
throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 不在当前诊所范围`); throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 不在当前诊所范围`);
} }
const patient = await this.prisma.patient.findUnique({ const patient = await this.prisma.patient.findUnique({
......
...@@ -71,12 +71,8 @@ export class PlansAggregateController { ...@@ -71,12 +71,8 @@ export class PlansAggregateController {
summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)', summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)',
description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。', description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。',
}) })
getFull( getFull(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
@TenantScope() scope: TenantScopeContext, return this.demo.getPlanDetailByPlanId(scope, planId);
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
) {
return this.demo.getPlanDetailByPlanId(scope, planId, user.permissions);
} }
// 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调: // 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
......
...@@ -76,12 +76,8 @@ export class PlanController { ...@@ -76,12 +76,8 @@ export class PlanController {
@Get(':id') @Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto }) @ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
detail( detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
@TenantScope() scope: TenantScopeContext, return this.plans.detail(scope, id);
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
return this.plans.detail(scope, id, user.permissions);
} }
@Post(':id/assign') @Post(':id/assign')
...@@ -142,12 +138,8 @@ export class PlanController { ...@@ -142,12 +138,8 @@ export class PlanController {
@ApiOperation({ @ApiOperation({
summary: 'Force a plan recompute for the patient owning this plan (ops/dev tool)', summary: 'Force a plan recompute for the patient owning this plan (ops/dev tool)',
}) })
async recompute( async recompute(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
@TenantScope() scope: TenantScopeContext, const plan = await this.plans.detail(scope, id);
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
const plan = await this.plans.detail(scope, id, user.permissions);
const result = await this.engine.recomputeForPatient({ const result = await this.engine.recomputeForPatient({
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
......
...@@ -201,8 +201,10 @@ export class PlanService { ...@@ -201,8 +201,10 @@ export class PlanService {
where.reasons = { some: { scenario: query.scenario } }; where.reasons = { some: { scenario: query.scenario } };
} }
// clinic 隔离(staff 只能看自己 clinic 的 target,或 null 集团池;leader 看全 tenant) // clinic 隔离:orgScope→clinicIds 是硬边界,始终生效(不按 view=all/PLAN_VIEW_ALL 豁免)。
if (view !== 'all' && scope.clinicIds.length > 0) { // view=all 只是"看本 scope 内所有经办人",不跨 orgScope 诊所;宽 scope(集团 leader/admin)
// clinicIds 覆盖全部 → 此过滤为 no-op,不影响其看全量。窄 scope 才被真正圈住(修越权)。
if (scope.clinicIds.length > 0) {
where.OR = [ where.OR = [
{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: { in: scope.clinicIds } },
{ targetClinicId: null }, { targetClinicId: null },
...@@ -366,11 +368,7 @@ export class PlanService { ...@@ -366,11 +368,7 @@ export class PlanService {
// detail // detail
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
async detail( async detail(scope: TenantScopeContext, planId: string): Promise<PlanDetailResponse> {
scope: TenantScopeContext,
planId: string,
permissions: readonly string[],
): Promise<PlanDetailResponse> {
const plan = await this.prisma.followupPlan.findFirst({ const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound // 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound
where: { where: {
...@@ -382,10 +380,9 @@ export class PlanService { ...@@ -382,10 +380,9 @@ export class PlanService {
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`); throw new NotFoundException(`Plan ${planId} not found`);
} }
// clinic 隔离:与 list 一致 —— 无 PLAN_VIEW_ALL 时,目标诊所须在 orgScope(clinicIds)内, // clinic 隔离:与 list 一致 —— orgScope→clinicIds 硬边界,目标诊所须在 clinicIds 内或为 null(集团池)。
// 或为 null(集团池)。否则按 ID 直取也不可见 —— 避免切诊所后残留的旧 URL 越权看别诊所患者 // 否则按 ID 直取也不可见 —— 修切诊所后残留旧 URL 越权看别诊所患者(view_all 不豁免,详见 list)
if ( if (
!permissions.includes(Permission.PLAN_VIEW_ALL) &&
scope.clinicIds.length > 0 && scope.clinicIds.length > 0 &&
plan.targetClinicId && plan.targetClinicId &&
!scope.clinicIds.includes(plan.targetClinicId) !scope.clinicIds.includes(plan.targetClinicId)
......
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