From 063049b7241d69ae508c3c677d3bf09d4be664c8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 2 Jul 2026 21:46:40 +0530 Subject: [PATCH] feat(p9): scope-ownership assert + fence routing approve/deny/listDecisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task T3.1: ActorResolver gains findScope (no-create) + assertOwns(principal, resourceScopeId) → ForbiddenException on mismatch (KG-02). RouteService.approve/ deny now assertOwns on the decision's binding scope before mutating; listDecisions is scoped to the caller. Fixes the critical cross-tenant mutation (tenant A could approve tenant B's route). Also adds IiosScope.cellId + IiosOutboundCommand.scopeId columns (migration tenant-isolation) + cell assignment on scope create. Test: another tenant approving → Forbidden, decision unchanged, no scope created, empty list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 5 +++ packages/iios-service/prisma/schema.prisma | 2 ++ .../src/identity/actor.resolver.ts | 33 ++++++++++++++++--- .../src/routing/route.controller.ts | 3 +- .../iios-service/src/routing/route.service.ts | 11 +++++-- .../iios-service/src/routing/route.spec.ts | 19 +++++++++++ 6 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 packages/iios-service/prisma/migrations/20260702161452_tenant_isolation/migration.sql diff --git a/packages/iios-service/prisma/migrations/20260702161452_tenant_isolation/migration.sql b/packages/iios-service/prisma/migrations/20260702161452_tenant_isolation/migration.sql new file mode 100644 index 0000000..4c08a67 --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260702161452_tenant_isolation/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "IiosOutboundCommand" ADD COLUMN "scopeId" TEXT; + +-- AlterTable +ALTER TABLE "IiosScope" ADD COLUMN "cellId" TEXT NOT NULL DEFAULT 'cell-default'; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index ae1ca98..d6feb45 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -296,6 +296,7 @@ model IiosScope { workspaceId String? projectId String? userScopeId String? + cellId String @default("cell-default") // P9 cell-partition hook createdAt DateTime @default(now()) sourceHandles IiosSourceHandle[] @@ -707,6 +708,7 @@ model IiosOutboundCommand { target String payload Json status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED + scopeId String? // owning tenant scope (P9 tenant fencing + per-tenant quota) idempotencyKey String @unique providerRef String? consentReceiptRef String? // CMP consent receipt this send went out under (P9) diff --git a/packages/iios-service/src/identity/actor.resolver.ts b/packages/iios-service/src/identity/actor.resolver.ts index e17f193..d07a5e7 100644 --- a/packages/iios-service/src/identity/actor.resolver.ts +++ b/packages/iios-service/src/identity/actor.resolver.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; /** The authenticated principal derived from the session port (token). */ @@ -22,15 +22,38 @@ export class ActorResolver { async resolveScope(principal: MessagePrincipal) { return ( - (await this.prisma.iiosScope.findFirst({ - where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null }, - })) ?? + (await this.findScope(principal)) ?? (await this.prisma.iiosScope.create({ - data: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId }, + data: { + orgId: principal.orgId, + appId: principal.appId, + tenantId: principal.tenantId, + // Cell-partition hook (P9): assign the tenant scope to a cell. + cellId: process.env.IIOS_CELL_ID ?? 'cell-default', + }, })) ); } + /** Resolve the caller's scope WITHOUT creating it — a caller with no scope owns nothing. */ + async findScope(principal: MessagePrincipal) { + return this.prisma.iiosScope.findFirst({ + where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null }, + }); + } + + /** + * Tenant fence (P9 / KG-02): throw unless the caller's scope owns the resource. + * Every by-id operation calls this before reading or mutating. + */ + async assertOwns(principal: MessagePrincipal, resourceScopeId: string) { + const scope = await this.findScope(principal); + if (!scope || scope.id !== resourceScopeId) { + throw new ForbiddenException('resource not in your tenant scope'); + } + return scope; + } + async resolveActor(scopeId: string, principal: MessagePrincipal) { const handle = await this.prisma.iiosSourceHandle.upsert({ where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } }, diff --git a/packages/iios-service/src/routing/route.controller.ts b/packages/iios-service/src/routing/route.controller.ts index 85cc87c..3a9bee7 100644 --- a/packages/iios-service/src/routing/route.controller.ts +++ b/packages/iios-service/src/routing/route.controller.ts @@ -34,8 +34,7 @@ export class RouteController { @Get('decisions') async decisions(@Query('state') state?: string, @Headers('authorization') auth?: string) { - this.principal(auth); - return this.routes.listDecisions({ state }); + return this.routes.listDecisions(this.principal(auth), { state }); } @Post('decisions/:id/approve') diff --git a/packages/iios-service/src/routing/route.service.ts b/packages/iios-service/src/routing/route.service.ts index 5cec06d..4ebdaef 100644 --- a/packages/iios-service/src/routing/route.service.ts +++ b/packages/iios-service/src/routing/route.service.ts @@ -43,9 +43,14 @@ export class RouteService { return this.simulator.simulate(interactionId, origin); } - async listDecisions(opts: { state?: string } = {}) { + async listDecisions(principal: MessagePrincipal, opts: { state?: string } = {}) { + const scope = await this.actors.findScope(principal); + if (!scope) return []; return this.prisma.iiosRouteDecision.findMany({ - where: opts.state ? { decisionState: opts.state as IiosRouteDecision['decisionState'] } : {}, + where: { + routeBinding: { scopeId: scope.id }, + ...(opts.state ? { decisionState: opts.state as IiosRouteDecision['decisionState'] } : {}), + }, orderBy: { createdAt: 'desc' }, take: 100, include: { routeBinding: true }, @@ -55,6 +60,7 @@ export class RouteService { async approve(decisionId: string, principal: MessagePrincipal) { const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } }); if (!decision) throw new NotFoundException('route decision not found'); + await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02) if (decision.decisionState === 'DENY' || decision.decisionState === 'SUPPRESS') { throw new BadRequestException(`cannot approve a ${decision.decisionState} decision`); } @@ -71,6 +77,7 @@ export class RouteService { async deny(decisionId: string, principal: MessagePrincipal) { const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } }); if (!decision) throw new NotFoundException('route decision not found'); + await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02) const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal); return this.prisma.iiosRouteDecision.update({ where: { id: decisionId }, diff --git a/packages/iios-service/src/routing/route.spec.ts b/packages/iios-service/src/routing/route.spec.ts index e28fb9e..7561bf9 100644 --- a/packages/iios-service/src/routing/route.spec.ts +++ b/packages/iios-service/src/routing/route.spec.ts @@ -123,6 +123,25 @@ describe('RouteService approve/deny → execute (P6)', () => { expect(await prisma.iiosOutboundCommand.count()).toBe(1); // dispatched to sandbox }); + it('tenant fence: another tenant cannot approve this tenant’s decision (KG-02)', async () => { + const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); + await makeBinding(scopeId, 'seniors', 'SENIORS'); + const service = svc(); + await service.simulate(interactionId, ORIGIN); + const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'seniors' } }); + const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({ + where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, + }); + + const tenantB: MessagePrincipal = { userId: 'intruder', orgId: 'org_other', appId: 'portal-demo', displayName: 'B' }; + await expect(service.approve(decision.id, tenantB)).rejects.toThrow(/tenant scope/); + const after = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decision.id } }); + expect(after.decisionState).toBe('REVIEW'); // unchanged + expect(await prisma.iiosScope.findFirst({ where: { orgId: 'org_other' } })).toBeNull(); // no scope created for B + // B's decision list is empty (scoped to caller) + expect(await service.listDecisions(tenantB)).toHaveLength(0); + }); + it('denying a decision never executes', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'children', 'CHILD');