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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user