Feat/s3 storage #2
@@ -49,3 +49,13 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
|||||||
# ── Capability providers (governed egress targets) ───────────────────────────
|
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||||
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||||
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
||||||
|
|
||||||
|
# ── Context attestation (July 12 trust layer) ──
|
||||||
|
# Audience IIOS requires on attestations addressed to it.
|
||||||
|
IIOS_ATTESTATION_AUDIENCE=iios-core
|
||||||
|
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
|
||||||
|
# crm-support-widget client into the registry when IIOS_DEV_TOKENS=1).
|
||||||
|
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
|
||||||
|
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
|
||||||
|
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
|
||||||
|
IIOS_REQUIRE_ATTESTATION=0
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
|
import { AttestationModule } from './platform/attestation.module';
|
||||||
import { IdentityModule } from './identity/identity.module';
|
import { IdentityModule } from './identity/identity.module';
|
||||||
import { InteractionsModule } from './interactions/interactions.module';
|
import { InteractionsModule } from './interactions/interactions.module';
|
||||||
import { IdempotencyModule } from './idempotency/idempotency.module';
|
import { IdempotencyModule } from './idempotency/idempotency.module';
|
||||||
@@ -27,6 +28,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
|
AttestationModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
InteractionsModule,
|
InteractionsModule,
|
||||||
IdempotencyModule,
|
IdempotencyModule,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Global, Module, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ContextAttestationVerifier } from './context-attestation';
|
||||||
|
import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores';
|
||||||
|
import { ContextAttestationGuard } from './context-attestation.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the context-attestation trust layer into DI and exposes the guard globally so any
|
||||||
|
* controller can enforce the three-proof gate. Also dev-seeds a registered client so locally
|
||||||
|
* minted attestations verify (prod registers clients out-of-band).
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
PrismaClientRegistry,
|
||||||
|
PrismaNonceStore,
|
||||||
|
{
|
||||||
|
provide: ContextAttestationVerifier,
|
||||||
|
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) =>
|
||||||
|
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }),
|
||||||
|
inject: [PrismaClientRegistry, PrismaNonceStore],
|
||||||
|
},
|
||||||
|
ContextAttestationGuard,
|
||||||
|
],
|
||||||
|
exports: [ContextAttestationVerifier, ContextAttestationGuard],
|
||||||
|
})
|
||||||
|
export class AttestationModule implements OnModuleInit {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Dev seed: register the CRM support client so an attestation signed with the shared dev
|
||||||
|
* secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (process.env.IIOS_DEV_TOKENS !== '1') return;
|
||||||
|
const secret = process.env.IIOS_ATTESTATION_DEV_SECRET;
|
||||||
|
if (!secret) return;
|
||||||
|
await this.prisma.iiosClientRegistry
|
||||||
|
.upsert({
|
||||||
|
where: { clientId: 'crm-support-widget' },
|
||||||
|
create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
|
||||||
|
update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import { ContextAttestationGuard } from './context-attestation.guard';
|
||||||
|
import {
|
||||||
|
ContextAttestationVerifier,
|
||||||
|
signDevAttestation,
|
||||||
|
type ClientRegistryPort,
|
||||||
|
type NonceStorePort,
|
||||||
|
} from './context-attestation';
|
||||||
|
import type { SessionVerifier } from './session.verifier';
|
||||||
|
|
||||||
|
const SECRET = 'appshell-dev-signing-key';
|
||||||
|
|
||||||
|
// SessionVerifier stand-in: the token always resolves to app "crm-web".
|
||||||
|
const fakeSession = {
|
||||||
|
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
|
||||||
|
} as unknown as SessionVerifier;
|
||||||
|
|
||||||
|
function makeGuard(): ContextAttestationGuard {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const registry: ClientRegistryPort = {
|
||||||
|
find: async (id) =>
|
||||||
|
id === 'crm-support-widget'
|
||||||
|
? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
|
||||||
|
const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' });
|
||||||
|
return new ContextAttestationGuard(fakeSession, verifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxWith(headers: Record<string, string>): ExecutionContext {
|
||||||
|
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
|
||||||
|
return signDevAttestation(
|
||||||
|
{ issuer: 'appshell.crm', audience: 'iios-core', clientId: 'crm-support-widget', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
|
||||||
|
secret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContextAttestationGuard (three-proof gate on the request path)', () => {
|
||||||
|
afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; });
|
||||||
|
|
||||||
|
it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => {
|
||||||
|
expect(await makeGuard().canActivate(ctxWith({}))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => {
|
||||||
|
process.env.IIOS_REQUIRE_ATTESTATION = '1';
|
||||||
|
await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a valid attestation bound to the token app', async () => {
|
||||||
|
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() };
|
||||||
|
expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => {
|
||||||
|
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') };
|
||||||
|
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a replayed attestation (same nonce twice)', async () => {
|
||||||
|
const guard = makeGuard();
|
||||||
|
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) };
|
||||||
|
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
|
||||||
|
await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { SessionVerifier } from './session.verifier';
|
||||||
|
import { ContextAttestationVerifier } from './context-attestation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by
|
||||||
|
* the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3:
|
||||||
|
* a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.).
|
||||||
|
*
|
||||||
|
* Behaviour (safe rollout):
|
||||||
|
* • attestation header present → verify it; the attestation's app_id must match the actor
|
||||||
|
* token's app_id. Any failure → 403.
|
||||||
|
* • attestation header absent → allowed ONLY when not required (dev / a zero-trust standalone
|
||||||
|
* caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403.
|
||||||
|
*
|
||||||
|
* The requirement is read per-request so it can be toggled without restarts and stays OFF by
|
||||||
|
* default — existing callers that don't send an attestation keep working until the rollout flips.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContextAttestationGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
private readonly attest: ContextAttestationVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||||
|
const required = process.env.IIOS_REQUIRE_ATTESTATION === '1';
|
||||||
|
const req = ctx.switchToHttp().getRequest<Request>();
|
||||||
|
const raw = req.headers['x-context-attestation'];
|
||||||
|
const attestation = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
|
||||||
|
if (!attestation) {
|
||||||
|
if (required) throw new ForbiddenException('context attestation required');
|
||||||
|
return true; // dev / zero-trust: the actor token is still enforced downstream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the attestation to the token's app_id, so a stolen attestation for another app fails.
|
||||||
|
const auth = req.headers['authorization'] ?? '';
|
||||||
|
const token = String(auth).replace(/^Bearer\s+/i, '');
|
||||||
|
let appId: string;
|
||||||
|
try {
|
||||||
|
appId = this.session.verify(token).appId ?? '';
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('invalid actor token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.attest.verify(attestation, appId);
|
||||||
|
if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { IiosTicketState } from '@prisma/client';
|
import { IiosTicketState } from '@prisma/client';
|
||||||
import { SupportService } from './support.service';
|
import { SupportService } from './support.service';
|
||||||
import { AssignmentService } from './assignment.service';
|
import { AssignmentService } from './assignment.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
||||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import {
|
import {
|
||||||
AssignTicketDto,
|
AssignTicketDto,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from './support.dto';
|
} from './support.dto';
|
||||||
|
|
||||||
@Controller('v1/support')
|
@Controller('v1/support')
|
||||||
|
@UseGuards(ContextAttestationGuard)
|
||||||
export class SupportController {
|
export class SupportController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly support: SupportService,
|
private readonly support: SupportService,
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ThreadsService } from './threads.service';
|
import { ThreadsService } from './threads.service';
|
||||||
import { MessageService } from '../messaging/message.service';
|
import { MessageService } from '../messaging/message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
||||||
import { SendMessageDto } from './send-message.dto';
|
import { SendMessageDto } from './send-message.dto';
|
||||||
|
|
||||||
@Controller('v1/threads')
|
@Controller('v1/threads')
|
||||||
|
@UseGuards(ContextAttestationGuard)
|
||||||
export class ThreadsController {
|
export class ThreadsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly threads: ThreadsService,
|
private readonly threads: ThreadsService,
|
||||||
|
|||||||
Reference in New Issue
Block a user