feat(iios): wire the three-proof gate into the request path (§1b)
Applies the context-attestation verifier at the request boundary via a guard, so a stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe to roll out. - ContextAttestationGuard: if an X-Context-Attestation header is present, verify it (its app_id must match the actor token's app_id) → 403 on any failure; if absent, allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is read per-request and defaults OFF, so existing callers keep working until AppShell/ be-crm start forwarding attestations. - AttestationModule (@Global): provides the verifier + Prisma stores + guard, and dev-seeds the crm-support-widget client so locally minted attestations verify. - Guard applied to ThreadsController + SupportController. - Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed. Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user