feat(p9): scope-ownership assert + fence routing approve/deny/listDecisions
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||||
@@ -296,6 +296,7 @@ model IiosScope {
|
|||||||
workspaceId String?
|
workspaceId String?
|
||||||
projectId String?
|
projectId String?
|
||||||
userScopeId String?
|
userScopeId String?
|
||||||
|
cellId String @default("cell-default") // P9 cell-partition hook
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
sourceHandles IiosSourceHandle[]
|
sourceHandles IiosSourceHandle[]
|
||||||
@@ -707,6 +708,7 @@ model IiosOutboundCommand {
|
|||||||
target String
|
target String
|
||||||
payload Json
|
payload Json
|
||||||
status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED
|
status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED
|
||||||
|
scopeId String? // owning tenant scope (P9 tenant fencing + per-tenant quota)
|
||||||
idempotencyKey String @unique
|
idempotencyKey String @unique
|
||||||
providerRef String?
|
providerRef String?
|
||||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/** The authenticated principal derived from the session port (token). */
|
/** The authenticated principal derived from the session port (token). */
|
||||||
@@ -22,15 +22,38 @@ export class ActorResolver {
|
|||||||
|
|
||||||
async resolveScope(principal: MessagePrincipal) {
|
async resolveScope(principal: MessagePrincipal) {
|
||||||
return (
|
return (
|
||||||
(await this.prisma.iiosScope.findFirst({
|
(await this.findScope(principal)) ??
|
||||||
where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null },
|
|
||||||
})) ??
|
|
||||||
(await this.prisma.iiosScope.create({
|
(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) {
|
async resolveActor(scopeId: string, principal: MessagePrincipal) {
|
||||||
const handle = await this.prisma.iiosSourceHandle.upsert({
|
const handle = await this.prisma.iiosSourceHandle.upsert({
|
||||||
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } },
|
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } },
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ export class RouteController {
|
|||||||
|
|
||||||
@Get('decisions')
|
@Get('decisions')
|
||||||
async decisions(@Query('state') state?: string, @Headers('authorization') auth?: string) {
|
async decisions(@Query('state') state?: string, @Headers('authorization') auth?: string) {
|
||||||
this.principal(auth);
|
return this.routes.listDecisions(this.principal(auth), { state });
|
||||||
return this.routes.listDecisions({ state });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('decisions/:id/approve')
|
@Post('decisions/:id/approve')
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ export class RouteService {
|
|||||||
return this.simulator.simulate(interactionId, origin);
|
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({
|
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' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
include: { routeBinding: true },
|
include: { routeBinding: true },
|
||||||
@@ -55,6 +60,7 @@ export class RouteService {
|
|||||||
async approve(decisionId: string, principal: MessagePrincipal) {
|
async approve(decisionId: string, principal: MessagePrincipal) {
|
||||||
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
|
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
|
||||||
if (!decision) throw new NotFoundException('route decision not found');
|
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') {
|
if (decision.decisionState === 'DENY' || decision.decisionState === 'SUPPRESS') {
|
||||||
throw new BadRequestException(`cannot approve a ${decision.decisionState} decision`);
|
throw new BadRequestException(`cannot approve a ${decision.decisionState} decision`);
|
||||||
}
|
}
|
||||||
@@ -71,6 +77,7 @@ export class RouteService {
|
|||||||
async deny(decisionId: string, principal: MessagePrincipal) {
|
async deny(decisionId: string, principal: MessagePrincipal) {
|
||||||
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
|
const decision = await this.prisma.iiosRouteDecision.findUnique({ where: { id: decisionId }, include: { routeBinding: true } });
|
||||||
if (!decision) throw new NotFoundException('route decision not found');
|
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);
|
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal);
|
||||||
return this.prisma.iiosRouteDecision.update({
|
return this.prisma.iiosRouteDecision.update({
|
||||||
where: { id: decisionId },
|
where: { id: decisionId },
|
||||||
|
|||||||
@@ -123,6 +123,25 @@ describe('RouteService approve/deny → execute (P6)', () => {
|
|||||||
expect(await prisma.iiosOutboundCommand.count()).toBe(1); // dispatched to sandbox
|
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 () => {
|
it('denying a decision never executes', async () => {
|
||||||
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||||
await makeBinding(scopeId, 'children', 'CHILD');
|
await makeBinding(scopeId, 'children', 'CHILD');
|
||||||
|
|||||||
Reference in New Issue
Block a user