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:
2026-07-02 21:46:40 +05:30
parent 27103529e6
commit 063049b724
6 changed files with 64 additions and 9 deletions
@@ -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?
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)