diff --git a/packages/iios-service/src/adapters/adapters.spec.ts b/packages/iios-service/src/adapters/adapters.spec.ts index beeeabe..ff30dd1 100644 --- a/packages/iios-service/src/adapters/adapters.spec.ts +++ b/packages/iios-service/src/adapters/adapters.spec.ts @@ -133,4 +133,15 @@ describe('Adapter outbound (P5)', () => { expect(cmd.status).toBe('BLOCKED'); expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0); }); + + it('per-tenant quota: a noisy tenant is capped while another still sends (P9 slice 3)', async () => { + const svc = outbound(); + svc.tenantLimit = 1; + const a1 = await svc.send('WEBHOOK', 't1', {}, 'ta-1', 'scope-A'); + const a2 = await svc.send('WEBHOOK', 't2', {}, 'ta-2', 'scope-A'); // A over its cap + const b1 = await svc.send('WEBHOOK', 't3', {}, 'tb-1', 'scope-B'); // B unaffected + expect(a1.status).toBe('SENT'); + expect(a2.status).toBe('RATE_LIMITED'); + expect(b1.status).toBe('SENT'); + }); }); diff --git a/packages/iios-service/src/adapters/outbound.service.ts b/packages/iios-service/src/adapters/outbound.service.ts index 66ea1cd..2bb8b2a 100644 --- a/packages/iios-service/src/adapters/outbound.service.ts +++ b/packages/iios-service/src/adapters/outbound.service.ts @@ -15,6 +15,9 @@ export class OutboundService { /** Public so tests can lower them; default from env. */ limit = Number(process.env.IIOS_OUTBOUND_LIMIT ?? 5); windowMs = Number(process.env.IIOS_OUTBOUND_WINDOW_MS ?? 60_000); + /** Per-tenant (scope) egress cap — noisy-neighbor protection (P9). */ + tenantLimit = Number(process.env.IIOS_TENANT_OUTBOUND_LIMIT ?? 10_000); + tenantWindowMs = Number(process.env.IIOS_TENANT_OUTBOUND_WINDOW_MS ?? 60_000); constructor( private readonly prisma: PrismaService, @@ -32,16 +35,17 @@ export class OutboundService { const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } }); if (existing) return existing; - if (!(await this.allow(channelType, target))) { + // Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress). + if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) { const cmd = await this.prisma.iiosOutboundCommand.create({ - data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey }, + data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey }, }); await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } }); return cmd; } const cmd = await this.prisma.iiosOutboundCommand.create({ - data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey }, + data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey }, }); let result; @@ -68,6 +72,14 @@ export class OutboundService { return updated; } + /** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */ + private async allowTenant(scopeId?: string): Promise { + if (!scopeId) return true; // unscoped sends are not tenant-limited + const since = new Date(Date.now() - this.tenantWindowMs); + const count = await this.prisma.iiosOutboundCommand.count({ where: { scopeId, createdAt: { gte: since } } }); + return count < this.tenantLimit; + } + /** Token/window bucket keyed by (channelType, target). Resets when the window elapses. */ private async allow(channelType: string, target: string): Promise { const now = new Date(); diff --git a/packages/iios-service/src/isolation.spec.ts b/packages/iios-service/src/isolation.spec.ts index a31f8a9..f5ad4c3 100644 --- a/packages/iios-service/src/isolation.spec.ts +++ b/packages/iios-service/src/isolation.spec.ts @@ -46,6 +46,7 @@ describe('Tenant isolation (P9 / KG-10 — cross-tenant blast radius)', () => { it('tenant B cannot read or mutate tenant A resources, and sees none of them', async () => { // ── Seed tenant A: a route decision, an AI artifact, a meeting ── const scopeA = await actors.resolveScope(A); + expect(scopeA.cellId).toBeTruthy(); // new scopes carry a cell assignment (P9 cell hook) const interactionId = await interactionFor(A); await prisma.iiosRouteBinding.create({ data: { scopeId: scopeA.id, originChannelType: 'WHATSAPP', originRef: 'adult-group', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true },