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:
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user