Compare commits
1 Commits
e5a2ebcf48
...
docs/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbb5eff8e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,6 +9,3 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
|
||||||
infra/step-ca/dev-password
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -53,7 +52,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTOs for the Step-CA client service (FED-M2-04).
|
|
||||||
*
|
|
||||||
* IssueCertRequestDto — input to CaService.issueCert()
|
|
||||||
* IssuedCertDto — output from CaService.issueCert()
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IsInt, IsNotEmpty, IsString, IsUUID, Max, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class IssueCertRequestDto {
|
|
||||||
/**
|
|
||||||
* PEM-encoded PKCS#10 Certificate Signing Request.
|
|
||||||
* The CSR must already include the desired SANs.
|
|
||||||
*/
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
csrPem!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UUID of the federation_grants row this certificate is being issued for.
|
|
||||||
* Embedded as the `mosaic_grant_id` custom OID extension.
|
|
||||||
*/
|
|
||||||
@IsUUID()
|
|
||||||
grantId!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UUID of the local user on whose behalf the cert is being issued.
|
|
||||||
* Embedded as the `mosaic_subject_user_id` custom OID extension.
|
|
||||||
*/
|
|
||||||
@IsUUID()
|
|
||||||
subjectUserId!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requested certificate validity in seconds.
|
|
||||||
* Capped at the step-ca provisioner policy ceiling.
|
|
||||||
* Defaults to 86 400 s (24 h) when omitted by callers.
|
|
||||||
*/
|
|
||||||
@IsInt()
|
|
||||||
@Min(60)
|
|
||||||
@Max(365 * 24 * 3600)
|
|
||||||
ttlSeconds!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IssuedCertDto {
|
|
||||||
/** PEM-encoded leaf certificate returned by step-ca. */
|
|
||||||
certPem!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PEM-encoded full certificate chain (leaf + intermediates + root).
|
|
||||||
* Falls back to `certPem` when step-ca returns no `certChain` field.
|
|
||||||
*/
|
|
||||||
certChainPem!: string;
|
|
||||||
|
|
||||||
/** Decimal serial number string of the issued certificate. */
|
|
||||||
serialNumber!: string;
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for CaService — Step-CA client (FED-M2-04).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
|
|
||||||
* - certChainPem fallback: falls back to certPem when certChain absent
|
|
||||||
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
|
|
||||||
* - HTTP 401: throws CaServiceError with cause + remediation
|
|
||||||
* - HTTP non-401 error: throws CaServiceError
|
|
||||||
* - Malformed CSR: throws before HTTP call
|
|
||||||
* - Non-JSON response: throws CaServiceError
|
|
||||||
* - HTTPS connection error: throws CaServiceError
|
|
||||||
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
|
|
||||||
* - CaServiceError: has cause + remediation properties
|
|
||||||
* - Missing crt in response: throws CaServiceError
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock node:https BEFORE importing CaService so the mock is in place when
|
|
||||||
// the module is loaded. Vitest/ESM require vi.mock at the top level.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
vi.mock('node:https', () => {
|
|
||||||
const mockRequest = vi.fn();
|
|
||||||
const mockAgent = vi.fn().mockImplementation(() => ({}));
|
|
||||||
return {
|
|
||||||
default: { request: mockRequest, Agent: mockAgent },
|
|
||||||
request: mockRequest,
|
|
||||||
Agent: mockAgent,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('node:fs', () => {
|
|
||||||
const mockReadFileSync = vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
|
|
||||||
return {
|
|
||||||
default: { readFileSync: mockReadFileSync },
|
|
||||||
readFileSync: mockReadFileSync,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Minimal self-signed certificate PEM produced by openssl for testing.
|
|
||||||
// Serial 01, RSA 512 bit (invalid for production, fine for unit tests).
|
|
||||||
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
|
||||||
-----END CERTIFICATE-----\n`;
|
|
||||||
|
|
||||||
const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST-----
|
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
|
||||||
-----END CERTIFICATE REQUEST-----\n`;
|
|
||||||
|
|
||||||
const FAKE_CA_PEM = `-----BEGIN CERTIFICATE-----
|
|
||||||
CAROOT000000000000000000000000000000000000000000000000AAAA
|
|
||||||
-----END CERTIFICATE-----\n`;
|
|
||||||
|
|
||||||
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
|
||||||
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Setup env before importing service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const JWK_KEY = JSON.stringify({
|
|
||||||
kty: 'oct',
|
|
||||||
kid: 'test-kid',
|
|
||||||
k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret")
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
|
|
||||||
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'test-password';
|
|
||||||
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JWK_KEY;
|
|
||||||
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
|
||||||
|
|
||||||
// Import AFTER env is set and mocks are registered
|
|
||||||
import * as httpsModule from 'node:https';
|
|
||||||
import { CaService, CaServiceError } from './ca.service.js';
|
|
||||||
import type { IssueCertRequestDto } from './ca.dto.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper to build a mock https.request that simulates step-ca
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
|
|
||||||
const mockReq = {
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
|
||||||
(
|
|
||||||
_options: unknown,
|
|
||||||
callback: (res: {
|
|
||||||
statusCode: number;
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
|
||||||
}) => void,
|
|
||||||
) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode,
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
|
||||||
if (event === 'data') {
|
|
||||||
if (body !== undefined) {
|
|
||||||
cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event === 'end') {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (errorMsg) {
|
|
||||||
// Simulate a connection error via the req.on('error') handler
|
|
||||||
mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
|
|
||||||
if (event === 'error') {
|
|
||||||
setImmediate(() => cb(new Error(errorMsg)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Normal flow: call the response callback
|
|
||||||
setImmediate(() => callback(mockRes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockReq;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('CaService', () => {
|
|
||||||
let service: CaService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
service = new CaService();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
|
||||||
return {
|
|
||||||
csrPem: FAKE_CSR_PEM,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
ttlSeconds: 86400,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Happy path
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns IssuedCertDto on success (certChain present)', async () => {
|
|
||||||
makeHttpsMock(200, {
|
|
||||||
crt: FAKE_CERT_PEM,
|
|
||||||
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.issueCert(makeReq());
|
|
||||||
|
|
||||||
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
|
||||||
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
|
||||||
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
|
||||||
expect(typeof result.serialNumber).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// certChainPem fallback — certChain absent, ca field present
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
|
||||||
makeHttpsMock(200, {
|
|
||||||
crt: FAKE_CERT_PEM,
|
|
||||||
ca: FAKE_CA_PEM,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.issueCert(makeReq());
|
|
||||||
|
|
||||||
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
|
||||||
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
|
||||||
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// certChainPem fallback — no certChain, no ca field
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
|
||||||
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
|
||||||
|
|
||||||
const result = await service.issueCert(makeReq());
|
|
||||||
|
|
||||||
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
|
||||||
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// HTTP 401
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError on HTTP 401', async () => {
|
|
||||||
makeHttpsMock(401, { message: 'Unauthorized' });
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
|
||||||
if (!(err instanceof CaServiceError)) return false;
|
|
||||||
expect(err.message).toMatch(/401/);
|
|
||||||
expect(err.remediation).toBeTruthy();
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// HTTP non-401 error (e.g. 422)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError on HTTP 422', async () => {
|
|
||||||
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Malformed CSR — throws before HTTP call
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
|
|
||||||
const requestSpy = vi.spyOn(httpsModule, 'request');
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
|
|
||||||
CaServiceError,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(requestSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Non-JSON response
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
|
||||||
makeHttpsMock(200, 'this is not json');
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
|
||||||
if (!(err instanceof CaServiceError)) return false;
|
|
||||||
expect(err.message).toMatch(/non-JSON/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// HTTPS connection error
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError on HTTPS connection error', async () => {
|
|
||||||
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
|
||||||
if (!(err instanceof CaServiceError)) return false;
|
|
||||||
expect(err.message).toMatch(/HTTPS connection/);
|
|
||||||
expect(err.cause).toBeInstanceOf(Error);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => {
|
|
||||||
let capturedBody: Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
// Override the mock to capture the request body
|
|
||||||
const mockReq = {
|
|
||||||
write: vi.fn((data: string) => {
|
|
||||||
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
|
||||||
}),
|
|
||||||
end: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
|
||||||
(
|
|
||||||
_options: unknown,
|
|
||||||
callback: (res: {
|
|
||||||
statusCode: number;
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
|
||||||
}) => void,
|
|
||||||
) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
|
||||||
if (event === 'data') {
|
|
||||||
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
|
|
||||||
}
|
|
||||||
if (event === 'end') {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setImmediate(() => callback(mockRes));
|
|
||||||
return mockReq;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.issueCert(makeReq());
|
|
||||||
|
|
||||||
expect(capturedBody).toBeDefined();
|
|
||||||
const ott = capturedBody!['ott'] as string;
|
|
||||||
expect(typeof ott).toBe('string');
|
|
||||||
|
|
||||||
// Decode JWT payload (second segment)
|
|
||||||
const parts = ott.split('.');
|
|
||||||
expect(parts).toHaveLength(3);
|
|
||||||
|
|
||||||
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
|
|
||||||
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
|
|
||||||
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// CaServiceError has cause + remediation
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('CaServiceError carries cause and remediation', () => {
|
|
||||||
const cause = new Error('original error');
|
|
||||||
const err = new CaServiceError('something went wrong', 'fix it like this', cause);
|
|
||||||
|
|
||||||
expect(err).toBeInstanceOf(Error);
|
|
||||||
expect(err).toBeInstanceOf(CaServiceError);
|
|
||||||
expect(err.message).toBe('something went wrong');
|
|
||||||
expect(err.remediation).toBe('fix it like this');
|
|
||||||
expect(err.cause).toBe(cause);
|
|
||||||
expect(err.name).toBe('CaServiceError');
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Missing crt in response
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws CaServiceError when response is missing the crt field', async () => {
|
|
||||||
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
|
||||||
if (!(err instanceof CaServiceError)) return false;
|
|
||||||
expect(err.message).toMatch(/missing the "crt" field/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
/**
|
|
||||||
* CaService — Step-CA client for federation grant certificate issuance.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with HS256
|
|
||||||
* carrying Mosaic-specific claims (`mosaic_grant_id`,
|
|
||||||
* `mosaic_subject_user_id`, `step.sha`) per the step-ca JWK provisioner
|
|
||||||
* protocol.
|
|
||||||
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
|
||||||
* pinning the trust to the CA root cert supplied via env.
|
|
||||||
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
|
||||||
* serial number.
|
|
||||||
*
|
|
||||||
* Environment variables (all required at runtime — validated in constructor):
|
|
||||||
* STEP_CA_URL https://step-ca:9000
|
|
||||||
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner public+private key (JSON)
|
|
||||||
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
|
||||||
*
|
|
||||||
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
|
||||||
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
|
||||||
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
|
||||||
*
|
|
||||||
* Fail-loud contract:
|
|
||||||
* Every error path throws CaServiceError with a human-readable `remediation`
|
|
||||||
* field. Silent OID-stripping is NEVER allowed — if the sign response does
|
|
||||||
* not include the cert, we throw rather than return a cert that may be
|
|
||||||
* missing the custom extensions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import * as https from 'node:https';
|
|
||||||
import type { IssueCertRequestDto } from './ca.dto.js';
|
|
||||||
import { IssuedCertDto } from './ca.dto.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Custom error class
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class CaServiceError extends Error {
|
|
||||||
readonly cause: unknown;
|
|
||||||
readonly remediation: string;
|
|
||||||
|
|
||||||
constructor(message: string, remediation: string, cause?: unknown) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CaServiceError';
|
|
||||||
this.cause = cause;
|
|
||||||
this.remediation = remediation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface StepSignResponse {
|
|
||||||
crt: string;
|
|
||||||
ca?: string;
|
|
||||||
certChain?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JwkKey {
|
|
||||||
kty: string;
|
|
||||||
kid?: string;
|
|
||||||
use?: string;
|
|
||||||
alg?: string;
|
|
||||||
k?: string; // symmetric
|
|
||||||
n?: string; // RSA
|
|
||||||
e?: string;
|
|
||||||
d?: string;
|
|
||||||
x?: string; // EC
|
|
||||||
y?: string;
|
|
||||||
crv?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64url-encode a Buffer or string (no padding).
|
|
||||||
*/
|
|
||||||
function b64url(input: Buffer | string): string {
|
|
||||||
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
|
||||||
return buf.toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
|
|
||||||
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
|
|
||||||
*/
|
|
||||||
function csrFingerprint(csrPem: string): string {
|
|
||||||
// Strip PEM headers and decode base64 body
|
|
||||||
const b64 = csrPem
|
|
||||||
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
|
|
||||||
.replace(/-----END CERTIFICATE REQUEST-----/, '')
|
|
||||||
.replace(/\s+/g, '');
|
|
||||||
|
|
||||||
let derBuf: Buffer;
|
|
||||||
try {
|
|
||||||
derBuf = Buffer.from(b64, 'base64');
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'Failed to base64-decode the CSR PEM body',
|
|
||||||
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (derBuf.length === 0) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'CSR PEM decoded to empty buffer — malformed input',
|
|
||||||
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a signing key from the JWK provisioner password using PBKDF2
|
|
||||||
* then sign with HMAC-SHA256 to produce an HS256 JWT.
|
|
||||||
*
|
|
||||||
* step-ca JWK provisioner tokens:
|
|
||||||
* - alg: HS256
|
|
||||||
* - header.kid: provisioner key ID
|
|
||||||
* - The key is the raw password bytes (step-ca uses the password directly
|
|
||||||
* as the HMAC key when the JWK provisioner type is "JWK" with symmetric
|
|
||||||
* key, or the password-derived key when encrypting the JWK).
|
|
||||||
*
|
|
||||||
* Per step-ca source (jose/jwk.go), for a JWK provisioner the OTT is a
|
|
||||||
* JWT signed with the provisioner's decrypted private key. For HS256 the
|
|
||||||
* key material is the `k` field of the JWK (symmetric secret), which itself
|
|
||||||
* was encrypted with the provisioner password. Since we already have the
|
|
||||||
* raw provisioner password we use it directly as the HMAC key — this mirrors
|
|
||||||
* what `step ca token` does for symmetric JWK provisioners.
|
|
||||||
*/
|
|
||||||
function buildOtt(params: {
|
|
||||||
caUrl: string;
|
|
||||||
provisionerPassword: string;
|
|
||||||
provisionerKeyJson: string;
|
|
||||||
csrPem: string;
|
|
||||||
grantId: string;
|
|
||||||
subjectUserId: string;
|
|
||||||
ttlSeconds: number;
|
|
||||||
}): string {
|
|
||||||
const {
|
|
||||||
caUrl,
|
|
||||||
provisionerPassword,
|
|
||||||
provisionerKeyJson,
|
|
||||||
csrPem,
|
|
||||||
grantId,
|
|
||||||
subjectUserId,
|
|
||||||
ttlSeconds,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
let jwk: JwkKey;
|
|
||||||
try {
|
|
||||||
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
|
||||||
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha = csrFingerprint(csrPem);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const kid = jwk.kid ?? 'mosaic-fed';
|
|
||||||
|
|
||||||
const header = {
|
|
||||||
alg: 'HS256',
|
|
||||||
typ: 'JWT',
|
|
||||||
kid,
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
iss: kid,
|
|
||||||
sub: `${caUrl}/1.0/sign`,
|
|
||||||
aud: [`${caUrl}/1.0/sign`],
|
|
||||||
iat: now,
|
|
||||||
nbf: now - 30, // 30 s clock-skew tolerance
|
|
||||||
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
|
||||||
sha,
|
|
||||||
// Mosaic custom claims consumed by federation.tpl
|
|
||||||
mosaic_grant_id: grantId,
|
|
||||||
mosaic_subject_user_id: subjectUserId,
|
|
||||||
// step.sha is the canonical field name used in the template
|
|
||||||
step: { sha },
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerB64 = b64url(JSON.stringify(header));
|
|
||||||
const payloadB64 = b64url(JSON.stringify(payload));
|
|
||||||
const signingInput = `${headerB64}.${payloadB64}`;
|
|
||||||
|
|
||||||
// Use the provisioner password as the raw HMAC-SHA256 key.
|
|
||||||
const hmac = crypto.createHmac('sha256', Buffer.from(provisionerPassword, 'utf8'));
|
|
||||||
hmac.update(signingInput);
|
|
||||||
const signature = hmac.digest();
|
|
||||||
|
|
||||||
return `${signingInput}.${b64url(signature)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a JSON POST to the step-ca sign endpoint.
|
|
||||||
* Returns the parsed response body or throws CaServiceError.
|
|
||||||
*/
|
|
||||||
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const bodyStr = JSON.stringify(body);
|
|
||||||
const parsed = new URL(url);
|
|
||||||
|
|
||||||
const options: https.RequestOptions = {
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
port: parsed.port ? parseInt(parsed.port, 10) : 443,
|
|
||||||
path: parsed.pathname,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Length': Buffer.byteLength(bodyStr),
|
|
||||||
},
|
|
||||||
agent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8');
|
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
|
||||||
reject(
|
|
||||||
new CaServiceError(
|
|
||||||
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
|
||||||
'Check STEP_CA_PROVISIONER_PASSWORD and STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode && res.statusCode >= 400) {
|
|
||||||
reject(
|
|
||||||
new CaServiceError(
|
|
||||||
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
|
|
||||||
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw) as unknown;
|
|
||||||
} catch (err) {
|
|
||||||
reject(
|
|
||||||
new CaServiceError(
|
|
||||||
'step-ca returned a non-JSON response',
|
|
||||||
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
|
|
||||||
err,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(parsed as StepSignResponse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (err: Error) => {
|
|
||||||
reject(
|
|
||||||
new CaServiceError(
|
|
||||||
`HTTPS connection to step-ca failed: ${err.message}`,
|
|
||||||
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
|
|
||||||
err,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.write(bodyStr);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a decimal serial number from a PEM certificate.
|
|
||||||
* Returns the hex serial if conversion is not possible.
|
|
||||||
*/
|
|
||||||
function extractSerial(certPem: string): string {
|
|
||||||
try {
|
|
||||||
const cert = new crypto.X509Certificate(certPem);
|
|
||||||
return cert.serialNumber;
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CaService {
|
|
||||||
private readonly logger = new Logger(CaService.name);
|
|
||||||
|
|
||||||
private readonly caUrl: string;
|
|
||||||
private readonly provisionerPassword: string;
|
|
||||||
private readonly provisionerKeyJson: string;
|
|
||||||
private readonly rootCertPath: string;
|
|
||||||
private readonly httpsAgent: https.Agent;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const caUrl = process.env['STEP_CA_URL'];
|
|
||||||
const provisionerPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
|
||||||
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
|
||||||
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
|
||||||
|
|
||||||
if (!caUrl) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_URL is not set',
|
|
||||||
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!provisionerPassword) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_PROVISIONER_PASSWORD is not set',
|
|
||||||
'Set STEP_CA_PROVISIONER_PASSWORD to the JWK provisioner password for the mosaic-fed provisioner.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!provisionerKeyJson) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
|
||||||
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!rootCertPath) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_ROOT_CERT_PATH is not set',
|
|
||||||
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.caUrl = caUrl;
|
|
||||||
this.provisionerPassword = provisionerPassword;
|
|
||||||
this.provisionerKeyJson = provisionerKeyJson;
|
|
||||||
this.rootCertPath = rootCertPath;
|
|
||||||
|
|
||||||
// Read the root cert and pin it for all HTTPS connections.
|
|
||||||
let rootCert: string;
|
|
||||||
try {
|
|
||||||
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
|
|
||||||
'Ensure the file exists and is readable by the gateway process.',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.httpsAgent = new https.Agent({
|
|
||||||
ca: rootCert,
|
|
||||||
rejectUnauthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a CSR to step-ca and return the issued certificate.
|
|
||||||
*
|
|
||||||
* Throws `CaServiceError` on any failure (network, auth, malformed input).
|
|
||||||
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
|
||||||
*/
|
|
||||||
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
|
||||||
this.logger.debug(
|
|
||||||
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${req.ttlSeconds}s`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate CSR before making network calls
|
|
||||||
if (!req.csrPem || !req.csrPem.includes('CERTIFICATE REQUEST')) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'csrPem does not appear to be a valid PKCS#10 PEM',
|
|
||||||
'Provide a PEM-encoded CSR starting with -----BEGIN CERTIFICATE REQUEST-----.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ott = buildOtt({
|
|
||||||
caUrl: this.caUrl,
|
|
||||||
provisionerPassword: this.provisionerPassword,
|
|
||||||
provisionerKeyJson: this.provisionerKeyJson,
|
|
||||||
csrPem: req.csrPem,
|
|
||||||
grantId: req.grantId,
|
|
||||||
subjectUserId: req.subjectUserId,
|
|
||||||
ttlSeconds: req.ttlSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signUrl = `${this.caUrl}/1.0/sign`;
|
|
||||||
const requestBody = {
|
|
||||||
csr: req.csrPem,
|
|
||||||
ott,
|
|
||||||
validity: {
|
|
||||||
duration: `${req.ttlSeconds}s`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.debug(`Posting CSR to ${signUrl}`);
|
|
||||||
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
|
|
||||||
|
|
||||||
if (!response.crt) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'step-ca sign response missing the "crt" field',
|
|
||||||
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
|
|
||||||
let certChainPem: string;
|
|
||||||
if (response.certChain && response.certChain.length > 0) {
|
|
||||||
certChainPem = response.certChain.join('\n');
|
|
||||||
} else if (response.ca) {
|
|
||||||
certChainPem = response.crt + '\n' + response.ca;
|
|
||||||
} else {
|
|
||||||
certChainPem = response.crt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serialNumber = extractSerial(response.crt);
|
|
||||||
|
|
||||||
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
|
|
||||||
|
|
||||||
const result = new IssuedCertDto();
|
|
||||||
result.certPem = response.crt;
|
|
||||||
result.certChainPem = certChainPem;
|
|
||||||
result.serialNumber = serialNumber;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { CaService } from './ca.service.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [CaService],
|
|
||||||
exports: [CaService],
|
|
||||||
})
|
|
||||||
export class FederationModule {}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for FederationScopeSchema and parseFederationScope.
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - Valid: minimal scope
|
|
||||||
* - Valid: full PRD §8.1 example
|
|
||||||
* - Valid: resources + excluded_resources (no overlap)
|
|
||||||
* - Invalid: empty resources
|
|
||||||
* - Invalid: unknown resource value
|
|
||||||
* - Invalid: resources / excluded_resources intersection
|
|
||||||
* - Invalid: filter key not in resources
|
|
||||||
* - Invalid: max_rows_per_query = 0
|
|
||||||
* - Invalid: max_rows_per_query = 10001
|
|
||||||
* - Invalid: not an object / null
|
|
||||||
* - Defaults: include_personal defaults to true; excluded_resources defaults to []
|
|
||||||
* - Sentinel: console.warn fires for sensitive resources
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
parseFederationScope,
|
|
||||||
FederationScopeError,
|
|
||||||
FederationScopeSchema,
|
|
||||||
} from './scope-schema.js';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFederationScope — valid inputs', () => {
|
|
||||||
it('accepts a minimal scope (resources + max_rows_per_query only)', () => {
|
|
||||||
const scope = parseFederationScope({
|
|
||||||
resources: ['tasks'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
});
|
|
||||||
expect(scope.resources).toEqual(['tasks']);
|
|
||||||
expect(scope.max_rows_per_query).toBe(100);
|
|
||||||
expect(scope.excluded_resources).toEqual([]);
|
|
||||||
expect(scope.filters).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts the full PRD §8.1 example', () => {
|
|
||||||
const scope = parseFederationScope({
|
|
||||||
resources: ['tasks', 'notes', 'memory'],
|
|
||||||
filters: {
|
|
||||||
tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true },
|
|
||||||
notes: { include_personal: true, include_teams: [] },
|
|
||||||
memory: { include_personal: true },
|
|
||||||
},
|
|
||||||
excluded_resources: ['credentials', 'api_keys'],
|
|
||||||
max_rows_per_query: 500,
|
|
||||||
});
|
|
||||||
expect(scope.resources).toEqual(['tasks', 'notes', 'memory']);
|
|
||||||
expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']);
|
|
||||||
expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']);
|
|
||||||
expect(scope.max_rows_per_query).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a scope with excluded_resources and no filter overlap', () => {
|
|
||||||
const scope = parseFederationScope({
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: ['memory'],
|
|
||||||
max_rows_per_query: 250,
|
|
||||||
});
|
|
||||||
expect(scope.resources).toEqual(['tasks', 'notes']);
|
|
||||||
expect(scope.excluded_resources).toEqual(['memory']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFederationScope — defaults', () => {
|
|
||||||
it('defaults excluded_resources to []', () => {
|
|
||||||
const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 });
|
|
||||||
expect(scope.excluded_resources).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults include_personal to true when filter is provided without it', () => {
|
|
||||||
const scope = parseFederationScope({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['t1'] } },
|
|
||||||
max_rows_per_query: 10,
|
|
||||||
});
|
|
||||||
expect(scope.filters?.tasks?.include_personal).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFederationScope — invalid inputs', () => {
|
|
||||||
it('throws FederationScopeError for empty resources array', () => {
|
|
||||||
expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow(
|
|
||||||
FederationScopeError,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for unknown resource value in resources', () => {
|
|
||||||
expect(() =>
|
|
||||||
parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }),
|
|
||||||
).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when resources and excluded_resources intersect', () => {
|
|
||||||
expect(() =>
|
|
||||||
parseFederationScope({
|
|
||||||
resources: ['tasks', 'memory'],
|
|
||||||
excluded_resources: ['memory'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
}),
|
|
||||||
).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when filters references a resource not in resources', () => {
|
|
||||||
expect(() =>
|
|
||||||
parseFederationScope({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { notes: { include_personal: true } },
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
}),
|
|
||||||
).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for max_rows_per_query = 0', () => {
|
|
||||||
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow(
|
|
||||||
FederationScopeError,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for max_rows_per_query = 10001', () => {
|
|
||||||
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow(
|
|
||||||
FederationScopeError,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for null input', () => {
|
|
||||||
expect(() => parseFederationScope(null)).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for non-object input (string)', () => {
|
|
||||||
expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFederationScope — sentinel warning', () => {
|
|
||||||
it('emits console.warn when resources includes "credentials"', () => {
|
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
parseFederationScope({
|
|
||||||
resources: ['tasks', 'credentials'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
});
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(
|
|
||||||
'[FederationScope] WARNING: scope grants sensitive resource "credentials"',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits console.warn when resources includes "api_keys"', () => {
|
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
parseFederationScope({
|
|
||||||
resources: ['tasks', 'api_keys'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
});
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(
|
|
||||||
'[FederationScope] WARNING: scope grants sensitive resource "api_keys"',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT emit console.warn for non-sensitive resources', () => {
|
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 });
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FederationScopeSchema — boundary values', () => {
|
|
||||||
it('accepts max_rows_per_query = 1 (lower bound)', () => {
|
|
||||||
const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 });
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts max_rows_per_query = 10000 (upper bound)', () => {
|
|
||||||
const result = FederationScopeSchema.safeParse({
|
|
||||||
resources: ['tasks'],
|
|
||||||
max_rows_per_query: 10000,
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation grant scope schema and validator.
|
|
||||||
*
|
|
||||||
* Source of truth: docs/federation/PRD.md §8.1
|
|
||||||
*
|
|
||||||
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
|
|
||||||
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Allowlist of federation resources (canonical — M3+ will extend this list)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export const FEDERATION_RESOURCE_VALUES = [
|
|
||||||
'tasks',
|
|
||||||
'notes',
|
|
||||||
'memory',
|
|
||||||
'credentials',
|
|
||||||
'api_keys',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sensitive resources require explicit admin approval (PRD §8.4).
|
|
||||||
* The parser warns when these appear in `resources`; M2-06 grant CRUD
|
|
||||||
* will add a hard gate on top of this warning.
|
|
||||||
*/
|
|
||||||
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sub-schemas
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const ResourceArraySchema = z
|
|
||||||
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
|
||||||
.nonempty({ message: 'resources must contain at least one value' })
|
|
||||||
.refine((arr) => new Set(arr).size === arr.length, {
|
|
||||||
message: 'resources must not contain duplicate values',
|
|
||||||
});
|
|
||||||
|
|
||||||
const ResourceFilterSchema = z.object({
|
|
||||||
include_teams: z.array(z.string()).optional(),
|
|
||||||
include_personal: z.boolean().default(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Top-level schema
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const FederationScopeSchema = z
|
|
||||||
.object({
|
|
||||||
resources: ResourceArraySchema,
|
|
||||||
|
|
||||||
excluded_resources: z
|
|
||||||
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
|
||||||
.default([])
|
|
||||||
.refine((arr) => new Set(arr).size === arr.length, {
|
|
||||||
message: 'excluded_resources must not contain duplicate values',
|
|
||||||
}),
|
|
||||||
|
|
||||||
filters: z.record(z.string(), ResourceFilterSchema).optional(),
|
|
||||||
|
|
||||||
max_rows_per_query: z
|
|
||||||
.number()
|
|
||||||
.int({ message: 'max_rows_per_query must be an integer' })
|
|
||||||
.min(1, { message: 'max_rows_per_query must be at least 1' })
|
|
||||||
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
const resourceSet = new Set(data.resources);
|
|
||||||
|
|
||||||
// Intersection guard: a resource cannot be both granted and excluded
|
|
||||||
for (const r of data.excluded_resources) {
|
|
||||||
if (resourceSet.has(r)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `Resource "${r}" appears in both resources and excluded_resources`,
|
|
||||||
path: ['excluded_resources'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter keys must be a subset of resources
|
|
||||||
if (data.filters) {
|
|
||||||
for (const key of Object.keys(data.filters)) {
|
|
||||||
if (!resourceSet.has(key as FederationResource)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `filters key "${key}" references a resource not present in resources`,
|
|
||||||
path: ['filters', key],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FederationScope = z.infer<typeof FederationScopeSchema>;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Error class
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class FederationScopeError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'FederationScopeError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Typed parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and validate an unknown value as a FederationScope.
|
|
||||||
*
|
|
||||||
* Throws `FederationScopeError` with aggregated Zod issues on failure.
|
|
||||||
*
|
|
||||||
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
|
|
||||||
* are present in `resources` — per PRD §8.4, these require explicit admin
|
|
||||||
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
|
|
||||||
*/
|
|
||||||
export function parseFederationScope(input: unknown): FederationScope {
|
|
||||||
const result = FederationScopeSchema.safeParse(input);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const issues = result.error.issues
|
|
||||||
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
|
||||||
.join('\n');
|
|
||||||
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = result.data;
|
|
||||||
|
|
||||||
// Sentinel warning for sensitive resources (PRD §8.4)
|
|
||||||
for (const resource of scope.resources) {
|
|
||||||
if (SENSITIVE_RESOURCES.has(resource)) {
|
|
||||||
console.warn(
|
|
||||||
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return scope;
|
|
||||||
}
|
|
||||||
@@ -30,10 +30,7 @@
|
|||||||
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
||||||
#
|
#
|
||||||
# IMAGE
|
# IMAGE
|
||||||
# Pinned to sha-9f1a081 (main HEAD post-#488 Dockerfile fix). The previous
|
# Pinned to digest fed-v0.1.0-m1 (DEPLOY-01 verified).
|
||||||
# pin (fed-v0.1.0-m1, sha256:9b72e2...) had a broken pnpm copy and could
|
|
||||||
# not resolve @mosaicstack/storage at runtime. The new digest was smoke-
|
|
||||||
# tested locally — gateway boots, imports resolve, tier-detector runs.
|
|
||||||
# Update digest here when promoting a new build.
|
# Update digest here when promoting a new build.
|
||||||
#
|
#
|
||||||
# NOTE: This is a TEST template — production deployments use a separate
|
# NOTE: This is a TEST template — production deployments use a separate
|
||||||
@@ -43,8 +40,8 @@ version: '3.9'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
gateway:
|
gateway:
|
||||||
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec
|
||||||
# Tag for human reference: sha-9f1a081 (post-#488 Dockerfile fix; smoke-tested locally)
|
# Tag for human reference: fed-v0.1.0-m1
|
||||||
environment:
|
environment:
|
||||||
# ── Tier ───────────────────────────────────────────────────────────────
|
# ── Tier ───────────────────────────────────────────────────────────────
|
||||||
MOSAIC_TIER: federated
|
MOSAIC_TIER: federated
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ services:
|
|||||||
postgres-federated:
|
postgres-federated:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
environment:
|
environment:
|
||||||
@@ -46,7 +45,6 @@ services:
|
|||||||
valkey-federated:
|
valkey-federated:
|
||||||
image: valkey/valkey:8-alpine
|
image: valkey/valkey:8-alpine
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -57,64 +55,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Step-CA — Mosaic Federation internal certificate authority
|
|
||||||
#
|
|
||||||
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
|
||||||
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
|
||||||
# reproducible deployments and digest-first promotion in CI).
|
|
||||||
#
|
|
||||||
# Profile: `federated` — this service must not start in non-federated dev.
|
|
||||||
#
|
|
||||||
# Password:
|
|
||||||
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
|
||||||
# ./infra/step-ca/dev-password.example and customise locally).
|
|
||||||
# Prod: replace the bind-mount with a Docker secret:
|
|
||||||
# secrets:
|
|
||||||
# ca_password:
|
|
||||||
# external: true
|
|
||||||
# and reference it as `/run/secrets/ca_password` (same path the
|
|
||||||
# init script already uses).
|
|
||||||
#
|
|
||||||
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
step-ca:
|
|
||||||
image: smallstep/step-ca:0.27.4
|
|
||||||
profiles: [federated]
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
|
||||||
volumes:
|
|
||||||
- step_ca_data:/home/step
|
|
||||||
# init script — executed as the container entrypoint
|
|
||||||
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
|
||||||
# X.509 template skeleton (wired in M2-04)
|
|
||||||
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
|
||||||
# Dev password file — GITIGNORED; copy from dev-password.example
|
|
||||||
# In production, replace this with a Docker secret (see comment above).
|
|
||||||
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
|
||||||
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
|
||||||
healthcheck:
|
|
||||||
# The healthcheck requires the root cert to exist, which is only true
|
|
||||||
# after init.sh has completed on first boot. start_period gives init
|
|
||||||
# time to finish before Docker starts counting retries.
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
'CMD',
|
|
||||||
'step',
|
|
||||||
'ca',
|
|
||||||
'health',
|
|
||||||
'--ca-url',
|
|
||||||
'https://localhost:9000',
|
|
||||||
'--root',
|
|
||||||
'/home/step/certs/root_ca.crt',
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_federated_data:
|
pg_federated_data:
|
||||||
valkey_federated_data:
|
valkey_federated_data:
|
||||||
step_ca_data:
|
|
||||||
|
|||||||
@@ -5,27 +5,18 @@ RUN corepack enable
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Copy workspace manifests first for layer-cached install
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
RUN pnpm --filter @mosaic/gateway build
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
|
||||||
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
|
||||||
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
|
||||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
|
COPY --from=builder /app/apps/gateway/package.json ./package.json
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -117,68 +117,3 @@ docker compose -f docker-compose.federated.yml logs valkey-federated
|
|||||||
```
|
```
|
||||||
|
|
||||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||||
|
|
||||||
## OID Assignments — Mosaic Internal OID Arc
|
|
||||||
|
|
||||||
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
|
||||||
certificate extensions in federation grant certificates.
|
|
||||||
|
|
||||||
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
|
||||||
production environment accessible by external parties, register a proper IANA
|
|
||||||
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
|
||||||
and update these assignments accordingly.
|
|
||||||
|
|
||||||
### Assigned OIDs
|
|
||||||
|
|
||||||
| OID | Symbolic name | Description |
|
|
||||||
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
|
||||||
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
|
||||||
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
|
||||||
|
|
||||||
### Encoding
|
|
||||||
|
|
||||||
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Tag 0x0C (UTF8String)
|
|
||||||
Length 0x24 (36 decimal — fixed length of a UUID string)
|
|
||||||
Value <36 ASCII bytes of the UUID>
|
|
||||||
```
|
|
||||||
|
|
||||||
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
|
||||||
produces this encoding via the Go template expression:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
|
||||||
```
|
|
||||||
|
|
||||||
The resulting base64 value is passed as the `value` field of the extension
|
|
||||||
object in the template JSON.
|
|
||||||
|
|
||||||
### CA Environment Variables
|
|
||||||
|
|
||||||
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
|
||||||
following environment variables at gateway startup:
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
|
||||||
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
|
||||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
|
||||||
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
|
||||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
|
||||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
|
||||||
|
|
||||||
Set these variables in your environment or secret manager before starting
|
|
||||||
the gateway. In the federated Docker Compose stack they are expected to be
|
|
||||||
injected via Docker secrets and environment variable overrides.
|
|
||||||
|
|
||||||
### Fail-loud contract
|
|
||||||
|
|
||||||
The CA service (and the X.509 template) are designed to fail loudly if the
|
|
||||||
custom OIDs cannot be embedded:
|
|
||||||
|
|
||||||
- The template produces a malformed extension value (zero-length UTF8String
|
|
||||||
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
|
||||||
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
|
||||||
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
|
||||||
a human-readable `remediation` string. It never silently returns a cert that
|
|
||||||
may be missing the required extensions.
|
|
||||||
|
|||||||
@@ -63,21 +63,21 @@ Goal: Two federated-tier gateways stood up on Portainer at `mos-test-1.woltje.co
|
|||||||
|
|
||||||
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
||||||
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
||||||
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
||||||
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. **Acceptance**: must (a) wire `federation.tpl` template into `mosaic-fed` provisioner config and (b) include a unit/integration test asserting issued certs contain BOTH OIDs — fails-loud guard against silent OID stripping (carry-forward from M2-02 review). |
|
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. |
|
||||||
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
||||||
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
||||||
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
||||||
| FED-M2-08 | not-started | Admin CLI: `mosaic federation grant create/list/show` + `peer add/list`. Integration with grants.service (no API duplication). Help output + machine-readable JSON option. | #461 | sonnet | feat/federation-m2-cli | M2-06, M2-07 | 7K | `peer add <enrollment-url>` is the client-side flow; resolves enrollment URL → CSR → store sealed key + cert. |
|
| FED-M2-08 | not-started | Admin CLI: `mosaic federation grant create/list/show` + `peer add/list`. Integration with grants.service (no API duplication). Help output + machine-readable JSON option. | #461 | sonnet | feat/federation-m2-cli | M2-06, M2-07 | 7K | `peer add <enrollment-url>` is the client-side flow; resolves enrollment URL → CSR → store sealed key + cert. |
|
||||||
| FED-M2-09 | not-started | Integration tests covering MILESTONES.md M2 acceptance tests #1, #2, #3, #5, #7, #8 (single-gateway suite). Real Step-CA container; vitest profile gated by `FEDERATED_INTEGRATION=1`. | #461 | sonnet | feat/federation-m2-integration | M2-08 | 8K | Tests #4 (cert OID match) + #6 (two-gateway peer-add) handled separately by M2-10 (E2E). |
|
| FED-M2-09 | not-started | Integration tests covering MILESTONES.md M2 acceptance tests #1, #2, #3, #5, #7, #8 (single-gateway suite). Real Step-CA container; vitest profile gated by `FEDERATED_INTEGRATION=1`. | #461 | sonnet | feat/federation-m2-integration | M2-08 | 8K | Tests #4 (cert OID match) + #6 (two-gateway peer-add) handled separately by M2-10 (E2E). |
|
||||||
| FED-M2-10 | not-started | E2E test against deployed mos-test-1 + mos-test-2 (or local two-gateway docker-compose if Portainer not ready): MILESTONES test #6 `peer add` yields `active` peer record with valid cert + key. | #461 | sonnet | feat/federation-m2-e2e | M2-08, DEPLOY-04 | 6K | Falls back to local docker-compose-two-gateways if remote test hosts not yet available. Documents both paths. |
|
| FED-M2-10 | not-started | E2E test against deployed mos-test-1 + mos-test-2 (or local two-gateway docker-compose if Portainer not ready): MILESTONES test #6 `peer add` yields `active` peer record with valid cert + key. | #461 | sonnet | feat/federation-m2-e2e | M2-08, DEPLOY-04 | 6K | Falls back to local docker-compose-two-gateways if remote test hosts not yet available. Documents both paths. |
|
||||||
| FED-M2-11 | not-started | Independent security review (sonnet, not author of M2-04/05/06/07): focus on single-use token replay, sealing leak surfaces, OID match enforcement, scope schema bypass paths. | #461 | sonnet | feat/federation-m2-security-review | M2-10 | 8K | Apply M1 two-round pattern. Reviewer should explicitly attempt enrollment-token replay, OID-spoofing CSR, and key leak in error messages. |
|
| FED-M2-11 | not-started | Independent security review (sonnet, not author of M2-04/05/06/07): focus on single-use token replay, sealing leak surfaces, OID match enforcement, scope schema bypass paths. | #461 | sonnet | feat/federation-m2-security-review | M2-10 | 8K | Apply M1 two-round pattern. Reviewer should explicitly attempt enrollment-token replay, OID-spoofing CSR, and key leak in error messages. |
|
||||||
| FED-M2-12 | not-started | Docs update: `docs/federation/SETUP.md` Step-CA section; new `docs/federation/ADMIN-CLI.md` with grant/peer commands; scope schema reference; OID registration note. Runbook still M7-deferred. | #461 | haiku | feat/federation-m2-docs | M2-11 | 4K | Adds CA bootstrap section to SETUP.md with `docker compose --profile federated up step-ca` example. |
|
| FED-M2-12 | not-started | Docs update: `docs/federation/SETUP.md` Step-CA section; new `docs/federation/ADMIN-CLI.md` with grant/peer commands; scope schema reference; OID registration note. Runbook still M7-deferred. | #461 | haiku | feat/federation-m2-docs | M2-11 | 4K | Adds CA bootstrap section to SETUP.md with `docker compose --profile federated up step-ca` example. |
|
||||||
| FED-M2-13 | not-started | PR aggregate close, CI green, merge to main, close #461. Release tag `fed-v0.2.0-m2`. Mark deploy stream complete. Update mission manifest M2 row. | #461 | sonnet | feat/federation-m2-close | M2-12 | 3K | Same close pattern as M1-12; queue-guard before merge; tea release-create with notes including deploy-stream PRs. |
|
| FED-M2-13 | not-started | PR aggregate close, CI green, merge to main, close #461. Release tag `fed-v0.2.0-m2`. Mark deploy stream complete. Update mission manifest M2 row. | #461 | sonnet | feat/federation-m2-close | M2-12 | 3K | Same close pattern as M1-12; queue-guard before merge; tea release-create with notes including deploy-stream PRs. |
|
||||||
|
|
||||||
**M2 code workstream estimate:** ~72K tokens (vs MILESTONES.md 30K — same over-budget pattern as M1, where per-task breakdown including tests/review/docs catches the real cost).
|
**M2 code workstream estimate:** ~72K tokens (vs MILESTONES.md 30K — same over-budget pattern as M1, where per-task breakdown including tests/review/docs catches the real cost).
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
dev-only-step-ca-password-do-not-use-in-production
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# infra/step-ca/init.sh
|
|
||||||
#
|
|
||||||
# Idempotent first-boot initialiser for the Mosaic Federation CA.
|
|
||||||
#
|
|
||||||
# On the first run (no /home/step/config/ca.json present) this script:
|
|
||||||
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
|
||||||
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
|
||||||
# 3. Copies the federation X.509 template into the CA config directory.
|
|
||||||
# 4. Patches the mosaic-fed provisioner entry in ca.json to reference the
|
|
||||||
# template via options.x509.templateFile (using jq — must be installed
|
|
||||||
# in the container image).
|
|
||||||
#
|
|
||||||
# On subsequent runs (config already exists) this script skips init and
|
|
||||||
# starts the CA directly.
|
|
||||||
#
|
|
||||||
# The provisioner name "mosaic-fed" is consumed by:
|
|
||||||
# apps/gateway/src/federation/ca.service.ts (added in M2-04)
|
|
||||||
#
|
|
||||||
# Password source:
|
|
||||||
# Dev: mounted from ./infra/step-ca/dev-password via bind mount.
|
|
||||||
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
|
||||||
#
|
|
||||||
# OID template:
|
|
||||||
# infra/step-ca/templates/federation.tpl emits custom OID extensions:
|
|
||||||
# 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
|
||||||
# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
CA_CONFIG="/home/step/config/ca.json"
|
|
||||||
PASSWORD_FILE="/run/secrets/ca_password"
|
|
||||||
TEMPLATE_SRC="/etc/step-ca-templates/federation.tpl"
|
|
||||||
TEMPLATE_DEST="/home/step/templates/federation.tpl"
|
|
||||||
|
|
||||||
if [ ! -f "${CA_CONFIG}" ]; then
|
|
||||||
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
|
||||||
|
|
||||||
step ca init \
|
|
||||||
--name "Mosaic Federation CA" \
|
|
||||||
--dns "localhost" \
|
|
||||||
--dns "step-ca" \
|
|
||||||
--address ":9000" \
|
|
||||||
--provisioner "mosaic-fed" \
|
|
||||||
--password-file "${PASSWORD_FILE}" \
|
|
||||||
--provisioner-password-file "${PASSWORD_FILE}" \
|
|
||||||
--no-db
|
|
||||||
|
|
||||||
echo "[step-ca init] CA initialised."
|
|
||||||
|
|
||||||
# Copy the X.509 template into the Step-CA config directory.
|
|
||||||
if [ -f "${TEMPLATE_SRC}" ]; then
|
|
||||||
mkdir -p /home/step/templates
|
|
||||||
cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}"
|
|
||||||
echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}."
|
|
||||||
else
|
|
||||||
echo "[step-ca init] WARNING: Template source ${TEMPLATE_SRC} not found — skipping copy."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wire the template into the mosaic-fed provisioner via jq.
|
|
||||||
# This is idempotent: the block only runs once (first boot).
|
|
||||||
#
|
|
||||||
# jq filter: find the provisioner entry with name "mosaic-fed" and set
|
|
||||||
# .options.x509.templateFile to the absolute path of the template.
|
|
||||||
# All other provisioners and config keys are left unchanged.
|
|
||||||
if [ -f "${TEMPLATE_DEST}" ] && command -v jq > /dev/null 2>&1; then
|
|
||||||
echo "[step-ca init] Patching mosaic-fed provisioner with X.509 template..."
|
|
||||||
TEMP_CONFIG="${CA_CONFIG}.tmp"
|
|
||||||
jq --arg tpl "${TEMPLATE_DEST}" '
|
|
||||||
.authority.provisioners |= map(
|
|
||||||
if .name == "mosaic-fed" then
|
|
||||||
.options.x509.templateFile = $tpl
|
|
||||||
else
|
|
||||||
.
|
|
||||||
end
|
|
||||||
)
|
|
||||||
' "${CA_CONFIG}" > "${TEMP_CONFIG}" && mv "${TEMP_CONFIG}" "${CA_CONFIG}"
|
|
||||||
echo "[step-ca init] Provisioner patched."
|
|
||||||
elif ! command -v jq > /dev/null 2>&1; then
|
|
||||||
echo "[step-ca init] WARNING: jq not found — skipping provisioner template patch."
|
|
||||||
echo "[step-ca init] Install jq in the step-ca image to enable automatic template wiring."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[step-ca init] Startup complete."
|
|
||||||
else
|
|
||||||
echo "[step-ca init] Config already exists — skipping init."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[step-ca init] Starting Step-CA on :9000..."
|
|
||||||
exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}"
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"subject": {{ toJson .Subject }},
|
|
||||||
"sans": {{ toJson .SANs }},
|
|
||||||
|
|
||||||
{{- /*
|
|
||||||
Mosaic Federation X.509 Certificate Template
|
|
||||||
============================================
|
|
||||||
Provisioner: mosaic-fed (JWK)
|
|
||||||
Implemented: FED-M2-04
|
|
||||||
|
|
||||||
This template emits two custom OID extensions carrying Mosaic federation
|
|
||||||
identifiers. The OTT token (built by CaService.buildOtt) MUST include the
|
|
||||||
claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT
|
|
||||||
claims. step-ca exposes them under `.Token.<claim>` in this template.
|
|
||||||
|
|
||||||
OID Registry (Mosaic Internal Arc — 1.3.6.1.4.1.99999):
|
|
||||||
1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars)
|
|
||||||
1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars)
|
|
||||||
|
|
||||||
DER encoding for each extension value (ASN.1 UTF8String):
|
|
||||||
Tag = 0x0C (UTF8String)
|
|
||||||
Length = 0x24 (decimal 36 — the fixed length of a UUID string)
|
|
||||||
Value = 36 ASCII bytes of the UUID
|
|
||||||
|
|
||||||
The `printf` below builds the raw TLV bytes then base64-encodes them.
|
|
||||||
step-ca expects the `value` field to be base64-encoded raw DER bytes.
|
|
||||||
|
|
||||||
Fail-loud contract:
|
|
||||||
If either claim is missing from the token the printf will produce a
|
|
||||||
zero-length UUID field, making the extension malformed. step-ca will
|
|
||||||
reject the certificate rather than issuing one without the required OIDs.
|
|
||||||
Silent OID stripping is NEVER tolerated.
|
|
||||||
|
|
||||||
Step-CA template reference:
|
|
||||||
https://smallstep.com/docs/step-ca/templates
|
|
||||||
*/ -}}
|
|
||||||
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"id": "1.3.6.1.4.1.99999.1",
|
|
||||||
"critical": false,
|
|
||||||
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1.3.6.1.4.1.99999.2",
|
|
||||||
"critical": false,
|
|
||||||
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_subject_user_id | b64enc }}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"keyUsage": ["digitalSignature"],
|
|
||||||
"extKeyUsage": ["clientAuth"],
|
|
||||||
"basicConstraints": {
|
|
||||||
"isCA": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
CREATE TYPE "public"."grant_status" AS ENUM('active', 'revoked', 'expired');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."peer_state" AS ENUM('pending', 'active', 'suspended', 'revoked');--> statement-breakpoint
|
|
||||||
CREATE TABLE "admin_tokens" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"token_hash" text NOT NULL,
|
|
||||||
"label" text NOT NULL,
|
|
||||||
"scope" text DEFAULT 'admin' NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone,
|
|
||||||
"last_used_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "federation_audit_log" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"request_id" text NOT NULL,
|
|
||||||
"peer_id" uuid,
|
|
||||||
"subject_user_id" text,
|
|
||||||
"grant_id" uuid,
|
|
||||||
"verb" text NOT NULL,
|
|
||||||
"resource" text NOT NULL,
|
|
||||||
"status_code" integer NOT NULL,
|
|
||||||
"result_count" integer,
|
|
||||||
"denied_reason" text,
|
|
||||||
"latency_ms" integer,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"query_hash" text,
|
|
||||||
"outcome" text,
|
|
||||||
"bytes_out" integer
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "federation_grants" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"subject_user_id" text NOT NULL,
|
|
||||||
"peer_id" uuid NOT NULL,
|
|
||||||
"scope" jsonb NOT NULL,
|
|
||||||
"status" "grant_status" DEFAULT 'active' NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"revoked_at" timestamp with time zone,
|
|
||||||
"revoked_reason" text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "federation_peers" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"common_name" text NOT NULL,
|
|
||||||
"display_name" text NOT NULL,
|
|
||||||
"cert_pem" text NOT NULL,
|
|
||||||
"cert_serial" text NOT NULL,
|
|
||||||
"cert_not_after" timestamp with time zone NOT NULL,
|
|
||||||
"client_key_pem" text,
|
|
||||||
"state" "peer_state" DEFAULT 'pending' NOT NULL,
|
|
||||||
"endpoint_url" text,
|
|
||||||
"last_seen_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"revoked_at" timestamp with time zone,
|
|
||||||
CONSTRAINT "federation_peers_common_name_unique" UNIQUE("common_name"),
|
|
||||||
CONSTRAINT "federation_peers_cert_serial_unique" UNIQUE("cert_serial")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "admin_tokens" ADD CONSTRAINT "admin_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint
|
|
||||||
CREATE INDEX "federation_peers_state_idx" ON "federation_peers" USING btree ("state");
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,13 +57,6 @@
|
|||||||
"when": 1774227064500,
|
"when": 1774227064500,
|
||||||
"tag": "0006_swift_shen",
|
"tag": "0006_swift_shen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1776822435828,
|
|
||||||
"tag": "0008_smart_lyja",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
/**
|
|
||||||
* FED-M2-01 — Integration test: federation DB schema (peers / grants / audit_log).
|
|
||||||
*
|
|
||||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
* (or any postgres with the mosaic schema already applied)
|
|
||||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/db test src/federation.integration.test.ts
|
|
||||||
*
|
|
||||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
|
||||||
*
|
|
||||||
* Strategy:
|
|
||||||
* - Applies the federation migration SQL directly (idempotent: CREATE TYPE/TABLE
|
|
||||||
* with IF NOT EXISTS guards applied via inline SQL before the migration DDL).
|
|
||||||
* - Assumes the base schema (users table etc.) already exists in the target DB.
|
|
||||||
* - All test rows use the `fed-m2-01-` prefix; cleanup in afterAll.
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* 1. Federation tables + enums apply cleanly against the existing schema.
|
|
||||||
* 2. Insert a sample user + peer + grant + audit row; verify round-trip.
|
|
||||||
* 3. FK cascade: deleting the user cascades to federation_grants.
|
|
||||||
* 4. FK set-null: deleting the peer sets federation_audit_log.peer_id to NULL.
|
|
||||||
* 5. Enum constraint: inserting an invalid status/state value throws a DB error.
|
|
||||||
* 6. Unique constraint: duplicate cert_serial throws a DB error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
|
|
||||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
/** Recognisable test-row prefix for safe cleanup without full-table truncation. */
|
|
||||||
const T = 'fed-m2-01';
|
|
||||||
|
|
||||||
// Deterministic IDs (UUID format required for uuid PK columns: 8-4-4-4-12 hex digits).
|
|
||||||
const PEER1_ID = `f2000001-0000-4000-8000-000000000001`;
|
|
||||||
const PEER2_ID = `f2000002-0000-4000-8000-000000000002`;
|
|
||||||
const USER1_ID = `${T}-user-1`;
|
|
||||||
|
|
||||||
let sql: ReturnType<typeof postgres> | undefined;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
if (!run) return;
|
|
||||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 10, idle_timeout: 10 });
|
|
||||||
|
|
||||||
// Apply the federation enums and tables idempotently.
|
|
||||||
// This mirrors the migration file but uses IF NOT EXISTS guards so it can run
|
|
||||||
// against a DB that may not have had drizzle migrations tracked.
|
|
||||||
await sql`
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE peer_state AS ENUM ('pending', 'active', 'suspended', 'revoked');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE grant_status AS ENUM ('active', 'revoked', 'expired');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS federation_peers (
|
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
common_name text NOT NULL,
|
|
||||||
display_name text NOT NULL,
|
|
||||||
cert_pem text NOT NULL,
|
|
||||||
cert_serial text NOT NULL,
|
|
||||||
cert_not_after timestamp with time zone NOT NULL,
|
|
||||||
client_key_pem text,
|
|
||||||
state peer_state NOT NULL DEFAULT 'pending',
|
|
||||||
endpoint_url text,
|
|
||||||
last_seen_at timestamp with time zone,
|
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
revoked_at timestamp with time zone,
|
|
||||||
CONSTRAINT federation_peers_common_name_unique UNIQUE (common_name),
|
|
||||||
CONSTRAINT federation_peers_cert_serial_unique UNIQUE (cert_serial)
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_peers_cert_serial_idx ON federation_peers (cert_serial)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_peers_state_idx ON federation_peers (state)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS federation_grants (
|
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
subject_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
peer_id uuid NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
|
||||||
scope jsonb NOT NULL,
|
|
||||||
status grant_status NOT NULL DEFAULT 'active',
|
|
||||||
expires_at timestamp with time zone,
|
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
revoked_at timestamp with time zone,
|
|
||||||
revoked_reason text
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_grants_subject_status_idx ON federation_grants (subject_user_id, status)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_grants_peer_status_idx ON federation_grants (peer_id, status)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS federation_audit_log (
|
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
request_id text NOT NULL,
|
|
||||||
peer_id uuid REFERENCES federation_peers(id) ON DELETE SET NULL,
|
|
||||||
subject_user_id text REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
grant_id uuid REFERENCES federation_grants(id) ON DELETE SET NULL,
|
|
||||||
verb text NOT NULL,
|
|
||||||
resource text NOT NULL,
|
|
||||||
status_code integer NOT NULL,
|
|
||||||
result_count integer,
|
|
||||||
denied_reason text,
|
|
||||||
latency_ms integer,
|
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
query_hash text,
|
|
||||||
outcome text,
|
|
||||||
bytes_out integer
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx
|
|
||||||
ON federation_audit_log (peer_id, created_at DESC NULLS LAST)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx
|
|
||||||
ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST)
|
|
||||||
`;
|
|
||||||
await sql`
|
|
||||||
CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx
|
|
||||||
ON federation_audit_log (created_at DESC NULLS LAST)
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (!sql) return;
|
|
||||||
|
|
||||||
// Cleanup in FK-safe order (children before parents).
|
|
||||||
await sql`DELETE FROM federation_audit_log WHERE request_id LIKE ${T + '%'}`.catch(() => {});
|
|
||||||
await sql`
|
|
||||||
DELETE FROM federation_grants
|
|
||||||
WHERE subject_user_id LIKE ${T + '%'}
|
|
||||||
OR revoked_reason LIKE ${T + '%'}
|
|
||||||
`.catch(() => {});
|
|
||||||
await sql`DELETE FROM federation_peers WHERE common_name LIKE ${T + '%'}`.catch(() => {});
|
|
||||||
await sql`DELETE FROM users WHERE id LIKE ${T + '%'}`.catch(() => {});
|
|
||||||
await sql.end({ timeout: 3 }).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federation schema — integration', () => {
|
|
||||||
// ── 1. Insert sample rows ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
it('inserts a user, peer, grant, and audit row without constraint violation', async () => {
|
|
||||||
const certPem = '-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----';
|
|
||||||
|
|
||||||
// User — BetterAuth users.id is text (any string, not uuid).
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
|
||||||
VALUES (${USER1_ID}, ${'M2-01 Test User'}, ${USER1_ID + '@example.com'}, false, now(), now())
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Peer
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_peers
|
|
||||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
|
||||||
VALUES (
|
|
||||||
${PEER1_ID},
|
|
||||||
${T + '-gateway-example-com'},
|
|
||||||
${'Test Peer'},
|
|
||||||
${certPem},
|
|
||||||
${T + '-serial-001'},
|
|
||||||
now() + interval '1 year',
|
|
||||||
${'active'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Grant — scope is jsonb; pass as JSON string and cast server-side.
|
|
||||||
const scopeJson = JSON.stringify({
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
operations: ['list', 'get'],
|
|
||||||
});
|
|
||||||
const grants = await sql!`
|
|
||||||
INSERT INTO federation_grants
|
|
||||||
(subject_user_id, peer_id, scope, status, created_at)
|
|
||||||
VALUES (
|
|
||||||
${USER1_ID},
|
|
||||||
${PEER1_ID},
|
|
||||||
${scopeJson}::jsonb,
|
|
||||||
${'active'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
expect(grants).toHaveLength(1);
|
|
||||||
const grantId = grants[0]!['id'] as string;
|
|
||||||
|
|
||||||
// Audit log row
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_audit_log
|
|
||||||
(request_id, peer_id, subject_user_id, grant_id, verb, resource, status_code, created_at)
|
|
||||||
VALUES (
|
|
||||||
${T + '-req-001'},
|
|
||||||
${PEER1_ID},
|
|
||||||
${USER1_ID},
|
|
||||||
${grantId},
|
|
||||||
${'list'},
|
|
||||||
${'tasks'},
|
|
||||||
${200},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Verify the audit row is present and has correct data.
|
|
||||||
const auditRows = await sql!`
|
|
||||||
SELECT * FROM federation_audit_log WHERE request_id = ${T + '-req-001'}
|
|
||||||
`;
|
|
||||||
expect(auditRows).toHaveLength(1);
|
|
||||||
expect(auditRows[0]!['status_code']).toBe(200);
|
|
||||||
expect(auditRows[0]!['verb']).toBe('list');
|
|
||||||
expect(auditRows[0]!['resource']).toBe('tasks');
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// ── 2. FK cascade: user delete cascades grants ─────────────────────────────
|
|
||||||
|
|
||||||
it('cascade-deletes federation_grants when the subject user is deleted', async () => {
|
|
||||||
const cascadeUserId = `${T}-cascade-user`;
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
|
||||||
VALUES (${cascadeUserId}, ${'Cascade User'}, ${cascadeUserId + '@example.com'}, false, now(), now())
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_grants
|
|
||||||
(subject_user_id, peer_id, scope, status, revoked_reason, created_at)
|
|
||||||
VALUES (
|
|
||||||
${cascadeUserId},
|
|
||||||
${PEER1_ID},
|
|
||||||
${scopeJson}::jsonb,
|
|
||||||
${'active'},
|
|
||||||
${T + '-cascade-test'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const before = await sql!`
|
|
||||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
|
||||||
`;
|
|
||||||
expect(before[0]!['cnt']).toBe(1);
|
|
||||||
|
|
||||||
// Delete user → grants should cascade-delete.
|
|
||||||
await sql!`DELETE FROM users WHERE id = ${cascadeUserId}`;
|
|
||||||
|
|
||||||
const after = await sql!`
|
|
||||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
|
||||||
`;
|
|
||||||
expect(after[0]!['cnt']).toBe(0);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// ── 3. FK set-null: peer delete sets audit_log.peer_id to NULL ────────────
|
|
||||||
|
|
||||||
it('sets federation_audit_log.peer_id to NULL when the peer is deleted', async () => {
|
|
||||||
// Insert a throwaway peer for this specific cascade test.
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_peers
|
|
||||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
|
||||||
VALUES (
|
|
||||||
${PEER2_ID},
|
|
||||||
${T + '-gateway-throwaway-com'},
|
|
||||||
${'Throwaway Peer'},
|
|
||||||
${'cert-pem-placeholder'},
|
|
||||||
${T + '-serial-002'},
|
|
||||||
now() + interval '1 year',
|
|
||||||
${'active'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
const reqId = `${T}-req-setnull`;
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_audit_log
|
|
||||||
(request_id, peer_id, subject_user_id, verb, resource, status_code, created_at)
|
|
||||||
VALUES (
|
|
||||||
${reqId},
|
|
||||||
${PEER2_ID},
|
|
||||||
${USER1_ID},
|
|
||||||
${'get'},
|
|
||||||
${'tasks'},
|
|
||||||
${200},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sql!`DELETE FROM federation_peers WHERE id = ${PEER2_ID}`;
|
|
||||||
|
|
||||||
const rows = await sql!`
|
|
||||||
SELECT peer_id FROM federation_audit_log WHERE request_id = ${reqId}
|
|
||||||
`;
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
expect(rows[0]!['peer_id']).toBeNull();
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// ── 4. Enum constraint: invalid grant_status rejected ─────────────────────
|
|
||||||
|
|
||||||
it('rejects an invalid grant_status value with a DB error', async () => {
|
|
||||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
|
||||||
await expect(
|
|
||||||
sql!`
|
|
||||||
INSERT INTO federation_grants
|
|
||||||
(subject_user_id, peer_id, scope, status, created_at)
|
|
||||||
VALUES (
|
|
||||||
${USER1_ID},
|
|
||||||
${PEER1_ID},
|
|
||||||
${scopeJson}::jsonb,
|
|
||||||
${'invalid_status'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
).rejects.toThrow();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
// ── 5. Enum constraint: invalid peer_state rejected ───────────────────────
|
|
||||||
|
|
||||||
it('rejects an invalid peer_state value with a DB error', async () => {
|
|
||||||
await expect(
|
|
||||||
sql!`
|
|
||||||
INSERT INTO federation_peers
|
|
||||||
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
|
||||||
VALUES (
|
|
||||||
${'bad-state-peer'},
|
|
||||||
${'Bad State'},
|
|
||||||
${'pem'},
|
|
||||||
${'bad-serial-999'},
|
|
||||||
now() + interval '1 year',
|
|
||||||
${'invalid_state'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
).rejects.toThrow();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
// ── 6. Unique constraint: duplicate cert_serial rejected ──────────────────
|
|
||||||
|
|
||||||
it('rejects a duplicate cert_serial with a unique constraint violation', async () => {
|
|
||||||
await expect(
|
|
||||||
sql!`
|
|
||||||
INSERT INTO federation_peers
|
|
||||||
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
|
||||||
VALUES (
|
|
||||||
${T + '-dup-cn'},
|
|
||||||
${'Dup Peer'},
|
|
||||||
${'pem'},
|
|
||||||
${T + '-serial-001'},
|
|
||||||
now() + interval '1 year',
|
|
||||||
${'pending'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
).rejects.toThrow();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
// ── 7. FK cascade: peer delete cascades to federation_grants ─────────────
|
|
||||||
|
|
||||||
it('cascade-deletes federation_grants when the owning peer is deleted', async () => {
|
|
||||||
const PEER3_ID = `f2000003-0000-4000-8000-000000000003`;
|
|
||||||
const cascadeGrantUserId = `${T}-cascade-grant-user`;
|
|
||||||
|
|
||||||
// Insert a dedicated user and peer for this test.
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
|
||||||
VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now())
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_peers
|
|
||||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
|
||||||
VALUES (
|
|
||||||
${PEER3_ID},
|
|
||||||
${T + '-gateway-cascade-peer'},
|
|
||||||
${'Cascade Peer'},
|
|
||||||
${'cert-pem-cascade'},
|
|
||||||
${T + '-serial-003'},
|
|
||||||
now() + interval '1 year',
|
|
||||||
${'active'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`;
|
|
||||||
|
|
||||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
|
||||||
await sql!`
|
|
||||||
INSERT INTO federation_grants
|
|
||||||
(subject_user_id, peer_id, scope, status, created_at)
|
|
||||||
VALUES (
|
|
||||||
${cascadeGrantUserId},
|
|
||||||
${PEER3_ID},
|
|
||||||
${scopeJson}::jsonb,
|
|
||||||
${'active'},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const before = await sql!`
|
|
||||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
|
||||||
`;
|
|
||||||
expect(before[0]!['cnt']).toBe(1);
|
|
||||||
|
|
||||||
// Delete peer → grants should cascade-delete.
|
|
||||||
await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`;
|
|
||||||
|
|
||||||
const after = await sql!`
|
|
||||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
|
||||||
`;
|
|
||||||
expect(after[0]!['cnt']).toBe(0);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {});
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation schema re-exports.
|
|
||||||
*
|
|
||||||
* The actual table and enum definitions live in schema.ts (alongside all other
|
|
||||||
* Drizzle tables) to avoid CJS/ESM cross-import issues when drizzle-kit loads
|
|
||||||
* schema files via esbuild-register. Application code that wants named imports
|
|
||||||
* for federation symbols should import from this file.
|
|
||||||
*
|
|
||||||
* M2-01: DB tables and enums only. No business logic.
|
|
||||||
* M2-03 will add JSON schema validation for the `scope` column.
|
|
||||||
* M4 will write rows to federation_audit_log.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
peerStateEnum,
|
|
||||||
grantStatusEnum,
|
|
||||||
federationPeers,
|
|
||||||
federationGrants,
|
|
||||||
federationAuditLog,
|
|
||||||
} from './schema.js';
|
|
||||||
@@ -2,7 +2,6 @@ export { createDb, type Db, type DbHandle } from './client.js';
|
|||||||
export { createPgliteDb } from './client-pglite.js';
|
export { createPgliteDb } from './client-pglite.js';
|
||||||
export { runMigrations } from './migrate.js';
|
export { runMigrations } from './migrate.js';
|
||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
export * from './federation.js';
|
|
||||||
export {
|
export {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
pgEnum,
|
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -586,194 +585,3 @@ export const summarizationJobs = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Federation ──────────────────────────────────────────────────────────────
|
|
||||||
// Enums declared before tables that reference them.
|
|
||||||
// All federation definitions live in this file (avoids CJS/ESM cross-import
|
|
||||||
// issues when drizzle-kit loads schema files via esbuild-register).
|
|
||||||
// Application code imports from `federation.ts` which re-exports from here.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifecycle state of a federation peer.
|
|
||||||
* - pending: registered but not yet approved / TLS handshake not confirmed
|
|
||||||
* - active: fully operational; mTLS verified
|
|
||||||
* - suspended: temporarily blocked; cert still valid
|
|
||||||
* - revoked: cert revoked; no traffic allowed
|
|
||||||
*/
|
|
||||||
export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspended', 'revoked']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifecycle state of a federation grant.
|
|
||||||
* - active: grant is in effect
|
|
||||||
* - revoked: manually revoked before expiry
|
|
||||||
* - expired: natural expiry (expires_at passed)
|
|
||||||
*/
|
|
||||||
export const grantStatusEnum = pgEnum('grant_status', ['active', 'revoked', 'expired']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A registered peer gateway identified by its Step-CA certificate CN.
|
|
||||||
* Represents both inbound peers (other gateways querying us) and outbound
|
|
||||||
* peers (gateways we query — identified by client_key_pem being set).
|
|
||||||
*/
|
|
||||||
export const federationPeers = pgTable(
|
|
||||||
'federation_peers',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
|
|
||||||
/** Certificate CN, e.g. "gateway-uscllc-com". Unique — one row per peer identity. */
|
|
||||||
commonName: text('common_name').notNull().unique(),
|
|
||||||
|
|
||||||
/** Human-friendly label shown in admin UI. */
|
|
||||||
displayName: text('display_name').notNull(),
|
|
||||||
|
|
||||||
/** Pinned PEM certificate used for mTLS verification. */
|
|
||||||
certPem: text('cert_pem').notNull(),
|
|
||||||
|
|
||||||
/** Certificate serial number — used for CRL / revocation lookup. */
|
|
||||||
certSerial: text('cert_serial').notNull().unique(),
|
|
||||||
|
|
||||||
/** Certificate expiry — used by the renewal scheduler (FED-M6). */
|
|
||||||
certNotAfter: timestamp('cert_not_after', { withTimezone: true }).notNull(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sealed (encrypted) private key for outbound connections TO this peer.
|
|
||||||
* NULL for inbound-only peer rows (we serve them; we don't call them).
|
|
||||||
*/
|
|
||||||
clientKeyPem: text('client_key_pem'),
|
|
||||||
|
|
||||||
/** Current peer lifecycle state. */
|
|
||||||
state: peerStateEnum('state').notNull().default('pending'),
|
|
||||||
|
|
||||||
/** Base URL for outbound queries, e.g. "https://woltje.com:443". NULL for inbound-only peers. */
|
|
||||||
endpointUrl: text('endpoint_url'),
|
|
||||||
|
|
||||||
/** Timestamp of the most recent successful inbound or outbound request. */
|
|
||||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
|
||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
|
|
||||||
/** Populated when the cert is revoked; NULL while the peer is active. */
|
|
||||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
// CRL / revocation lookups by serial.
|
|
||||||
index('federation_peers_cert_serial_idx').on(t.certSerial),
|
|
||||||
// Filter peers by state (e.g. find all active peers for outbound routing).
|
|
||||||
index('federation_peers_state_idx').on(t.state),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A grant lets a specific peer cert query a specific local user's data within
|
|
||||||
* a defined scope. Scopes are validated by JSON Schema in M2-03; this table
|
|
||||||
* stores them as raw jsonb.
|
|
||||||
*/
|
|
||||||
export const federationGrants = pgTable(
|
|
||||||
'federation_grants',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The local user whose data this grant exposes.
|
|
||||||
* Cascade delete: if the user account is deleted, revoke all their grants.
|
|
||||||
*/
|
|
||||||
subjectUserId: text('subject_user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The peer gateway holding the grant.
|
|
||||||
* Cascade delete: if the peer record is removed, the grant is moot.
|
|
||||||
*/
|
|
||||||
peerId: uuid('peer_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope object — validated by JSON Schema (M2-03).
|
|
||||||
* Example: { "resources": ["tasks", "notes"], "operations": ["list", "get"] }
|
|
||||||
*/
|
|
||||||
scope: jsonb('scope').notNull(),
|
|
||||||
|
|
||||||
/** Current grant lifecycle state. */
|
|
||||||
status: grantStatusEnum('status').notNull().default('active'),
|
|
||||||
|
|
||||||
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
|
|
||||||
/** Populated when the grant is explicitly revoked. */
|
|
||||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
||||||
|
|
||||||
/** Human-readable reason for revocation (audit trail). */
|
|
||||||
revokedReason: text('revoked_reason'),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
// Hot path: look up active grants for a subject user (auth middleware).
|
|
||||||
index('federation_grants_subject_status_idx').on(t.subjectUserId, t.status),
|
|
||||||
// Hot path: look up active grants held by a peer (inbound request check).
|
|
||||||
index('federation_grants_peer_status_idx').on(t.peerId, t.status),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append-only audit log of all federation requests.
|
|
||||||
* M4 writes rows here. M2 only creates the table.
|
|
||||||
*
|
|
||||||
* All FKs use SET NULL so audit rows survive peer/user/grant deletion.
|
|
||||||
*/
|
|
||||||
export const federationAuditLog = pgTable(
|
|
||||||
'federation_audit_log',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
|
|
||||||
/** UUIDv7 from the X-Request-ID header — correlates with OTEL traces. */
|
|
||||||
requestId: text('request_id').notNull(),
|
|
||||||
|
|
||||||
/** Peer that made the request. SET NULL if the peer is later deleted. */
|
|
||||||
peerId: uuid('peer_id').references(() => federationPeers.id, { onDelete: 'set null' }),
|
|
||||||
|
|
||||||
/** Subject user whose data was queried. SET NULL if the user is deleted. */
|
|
||||||
subjectUserId: text('subject_user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
||||||
|
|
||||||
/** Grant under which the request was authorised. SET NULL if the grant is deleted. */
|
|
||||||
grantId: uuid('grant_id').references(() => federationGrants.id, { onDelete: 'set null' }),
|
|
||||||
|
|
||||||
/** Request verb: "list" | "get" | "search". */
|
|
||||||
verb: text('verb').notNull(),
|
|
||||||
|
|
||||||
/** Resource type: "tasks" | "notes" | "memory" | etc. */
|
|
||||||
resource: text('resource').notNull(),
|
|
||||||
|
|
||||||
/** HTTP status code returned to the peer. */
|
|
||||||
statusCode: integer('status_code').notNull(),
|
|
||||||
|
|
||||||
/** Number of items returned (NULL for non-list requests or errors). */
|
|
||||||
resultCount: integer('result_count'),
|
|
||||||
|
|
||||||
/** Why the request was denied (NULL when allowed). */
|
|
||||||
deniedReason: text('denied_reason'),
|
|
||||||
|
|
||||||
/** End-to-end latency in milliseconds. */
|
|
||||||
latencyMs: integer('latency_ms'),
|
|
||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
|
|
||||||
// Reserved for M4 — see PRD 7.3
|
|
||||||
/** SHA-256 of the normalised GraphQL/REST query string; written by M4 search. */
|
|
||||||
queryHash: text('query_hash'),
|
|
||||||
/** Request outcome: "allowed" | "denied" | "partial"; written by M4. */
|
|
||||||
outcome: text('outcome'),
|
|
||||||
/** Response payload size in bytes; written by M4. */
|
|
||||||
bytesOut: integer('bytes_out'),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
// Per-peer request history in reverse chronological order.
|
|
||||||
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
|
|
||||||
// Per-user access log in reverse chronological order.
|
|
||||||
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
|
|
||||||
// Global time-range scans (dashboards, rate-limit windows).
|
|
||||||
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user