diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 31bae1c..1212e62 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; +import { PlatformModule } from './platform/platform.module'; import { HealthController } from './health.controller'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, PlatformModule], controllers: [HealthController], }) export class AppModule {} diff --git a/packages/iios-service/src/platform/fail-closed.test.ts b/packages/iios-service/src/platform/fail-closed.test.ts new file mode 100644 index 0000000..ee7bf2b --- /dev/null +++ b/packages/iios-service/src/platform/fail-closed.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { PolicyDeniedError } from '@insignia/iios-contracts'; +import { decideOrThrow } from './fail-closed'; + +describe('decideOrThrow (fail-closed gate)', () => { + it('returns the decision when OPA allows', async () => { + const ports = makeFakePorts(); + const decision = await decideOrThrow(ports, {}); + expect(decision.allow).toBe(true); + }); + + it('throws PolicyDeniedError when OPA denies', async () => { + const ports = makeFakePorts(); + ports.opa.deny('blocked'); + await expect(decideOrThrow(ports, {})).rejects.toBeInstanceOf(PolicyDeniedError); + }); + + it('throws PolicyDeniedError when OPA times out (never implicit-allow)', async () => { + const ports = makeFakePorts(); + ports.opa.timeout(); + await expect(decideOrThrow(ports, {})).rejects.toBeInstanceOf(PolicyDeniedError); + }); +}); diff --git a/packages/iios-service/src/platform/fail-closed.ts b/packages/iios-service/src/platform/fail-closed.ts new file mode 100644 index 0000000..77c88cd --- /dev/null +++ b/packages/iios-service/src/platform/fail-closed.ts @@ -0,0 +1,21 @@ +import { IiosPlatformPorts, PolicyDecision, PolicyDeniedError } from '@insignia/iios-contracts'; + +/** + * Fail-closed authorization (Bottom-Up hard gate; Critics KG-03). + * + * Calls the OPA port. The kernel proceeds ONLY on an explicit allow. A denial, + * a thrown error, or a timeout all become a PolicyDeniedError — never an + * implicit allow. Callers must let this propagate so nothing is written. + */ +export async function decideOrThrow(ports: IiosPlatformPorts, input: unknown): Promise { + let decision: PolicyDecision; + try { + decision = await ports.opa.decide(input); + } catch (err) { + throw new PolicyDeniedError(`policy port unavailable: ${(err as Error).message}`); + } + if (!decision.allow) { + throw new PolicyDeniedError(decision.obligations[0]?.reason ?? 'policy denied'); + } + return decision; +} diff --git a/packages/iios-service/src/platform/platform-ports.ts b/packages/iios-service/src/platform/platform-ports.ts new file mode 100644 index 0000000..d8ff652 --- /dev/null +++ b/packages/iios-service/src/platform/platform-ports.ts @@ -0,0 +1,55 @@ +import type { + IiosPlatformPorts, + PlatformPrincipal, + PolicyDecision, + ContextDecisionBundle, + SourceHandleResolution, +} from '@insignia/iios-contracts'; + +/** DI token for the platform ports (Session/OPA/CMP/MDM/CRRE/SAS/Capability). */ +export const PLATFORM_PORTS = Symbol('PLATFORM_PORTS'); + +/** + * P1 default binding: a permissive, in-service implementation so `nest start` + * runs without the real platform fabric (which other teams own) and without a + * runtime dependency on the testkit. Tests inject @insignia/iios-testkit fakes + * to exercise deny / timeout / ambiguous paths. Real adapters replace this from + * P5 onward. + */ +export class LocalDevPorts implements IiosPlatformPorts { + session = { + async verify(_token: string): Promise { + return { principalRef: 'local-dev', orgId: 'org_demo', appId: 'portal-demo', assurance: 'service' }; + }, + }; + opa = { + async decide(_input: unknown): Promise { + return { decisionRef: 'local-allow', allow: true, obligations: [], ttlSeconds: 60 }; + }, + }; + cmp = { + async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> { + return { status: 'NOT_REQUIRED' }; + }, + }; + mdm = { + async resolveSourceHandle(_input: unknown): Promise { + return { confidence: 0, status: 'UNRESOLVED' }; + }, + }; + crre = { + async resolve(_input: unknown): Promise { + return { bundleRef: 'local', scopeHash: 'local', resourceSet: [], constraints: [] }; + }, + }; + sas = { + async tokenizeParts(parts: unknown[]): Promise { + return parts; + }, + }; + capability = { + async invoke(_input: unknown): Promise<{ providerRef: string; outcome: string }> { + return { providerRef: 'local', outcome: 'SENT' }; + }, + }; +} diff --git a/packages/iios-service/src/platform/platform.module.ts b/packages/iios-service/src/platform/platform.module.ts new file mode 100644 index 0000000..c69c2a1 --- /dev/null +++ b/packages/iios-service/src/platform/platform.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports'; + +/** Binds the platform ports (P1: the permissive in-service default). */ +@Global() +@Module({ + providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }], + exports: [PLATFORM_PORTS], +}) +export class PlatformModule {}