feat(casan): establish assurance kernel and harden control plane

This commit is contained in:
thanhnv
2026-08-02 23:24:51 +07:00
parent 8b477f3800
commit 5745519126
51 changed files with 4076 additions and 180 deletions
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from './app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
import { UnauthorizedException } from '@nestjs/common';
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
@@ -23,7 +24,7 @@ function mapClaim(claim: string): string | null {
}
}
function roleFromClaim(raw: string | undefined): string {
export function roleFromClaim(raw: string | undefined): string {
if (!raw) return 'viewer';
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
for (const claim of claims) {
@@ -33,6 +34,23 @@ function roleFromClaim(raw: string | undefined): string {
return 'viewer';
}
export interface VerifiedClaimInput {
subject: string;
issuer: string;
roles: string[];
project: string;
tenant: string;
}
export function actorFromVerifiedClaims(claims: VerifiedClaimInput): SettingsActor {
return {
actor: claims.subject,
role: roleFromClaim(claims.roles.join(',')),
project: /^[A-Za-z0-9._-]+$/.test(claims.project) ? claims.project : 'default',
tenant: /^[A-Za-z0-9._-]+$/.test(claims.tenant) ? claims.tenant : 'default',
};
}
function projectFromClaims(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const prefix = 'casan-project:';
@@ -42,6 +60,9 @@ function projectFromClaims(raw: string | undefined): string | undefined {
}
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
if (process.env.CASAN_CP_AUTH_MODE === 'jwt' && firstHeader(headers['x-casan-identity-verified']) !== '1') {
throw new UnauthorizedException('AUTH_VERIFIED_IDENTITY_REQUIRED');
}
const actor = firstHeader(headers['x-casan-actor'])
|| firstHeader(headers['x-auth-request-user'])
|| firstHeader(headers['x-forwarded-user'])
@@ -0,0 +1,270 @@
import { createHash, createHmac, createPublicKey, createVerify, timingSafeEqual } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { NextFunction, Request, Response } from 'express';
import { APP_ROOT } from './app-root.js';
import { actorFromVerifiedClaims, type VerifiedClaimInput } from './auth-context.js';
export type AuthMode = 'local' | 'jwt';
export type JwtAlgorithm = 'RS256' | 'HS256';
export interface ControlPlaneAuthConfig {
mode: AuthMode;
profile: string;
bind: string;
issuer?: string;
audience?: string;
algorithm?: JwtAlgorithm;
publicKey?: string;
hmacSecret?: string;
clockSkewSeconds: number;
roleClaim: string;
tenantClaim: string;
projectClaim: string;
}
export interface VerifiedClaims {
subject: string;
issuer: string;
audience: string[];
expiresAt: number;
issuedAt?: number;
roles: string[];
tenant: string;
project: string;
authenticationMethod: 'jwt';
}
export interface AuthenticationDecision {
allowed: boolean;
reasonCode: string;
claims?: VerifiedClaims;
}
export interface AuthProvider {
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds?: number): AuthenticationDecision;
}
interface JwtHeader {
alg?: string;
typ?: string;
}
type JwtPayload = Record<string, unknown>;
const IDENTITY_HEADERS = [
'x-casan-actor', 'x-casan-role', 'x-casan-groups', 'x-casan-project', 'x-casan-tenant',
'x-auth-request-user', 'x-auth-request-groups', 'x-forwarded-user', 'x-forwarded-groups',
'x-casan-identity-verified', 'x-casan-identity-issuer',
];
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function decodeSegment<T>(segment: string): T {
const decoded = Buffer.from(segment, 'base64url').toString('utf8');
const payload: unknown = JSON.parse(decoded);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('jwt_segment_not_object');
return payload as T;
}
function stringClaim(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function stringListClaim(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
if (typeof value === 'string') return value.split(/[\s,]+/).filter(Boolean);
return [];
}
function audienceClaim(value: unknown): string[] {
return typeof value === 'string' ? [value] : stringListClaim(value);
}
function numericClaim(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function safeScope(value: string | undefined, fallback: string): string {
return value && /^[A-Za-z0-9._-]+$/.test(value) ? value : fallback;
}
function verifySignature(input: string, signature: Buffer, config: ControlPlaneAuthConfig): boolean {
if (config.algorithm === 'RS256' && config.publicKey) {
const verifier = createVerify('RSA-SHA256');
verifier.update(input);
verifier.end();
return verifier.verify(config.publicKey, signature);
}
if (config.algorithm === 'HS256' && config.hmacSecret) {
const expected = createHmac('sha256', config.hmacSecret).update(input).digest();
return expected.length === signature.length && timingSafeEqual(expected, signature);
}
return false;
}
export class JwtAuthProvider implements AuthProvider {
constructor(private readonly config: ControlPlaneAuthConfig) {}
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds = Math.floor(Date.now() / 1000)): AuthenticationDecision {
const authorization = firstHeader(headers.authorization);
if (!authorization?.startsWith('Bearer ')) return { allowed: false, reasonCode: 'auth_bearer_token_required' };
const token = authorization.slice('Bearer '.length).trim();
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !part)) return { allowed: false, reasonCode: 'auth_token_malformed' };
try {
const header = decodeSegment<JwtHeader>(parts[0]);
const payload = decodeSegment<JwtPayload>(parts[1]);
if (header.alg !== this.config.algorithm) return { allowed: false, reasonCode: 'auth_algorithm_mismatch' };
if (!verifySignature(`${parts[0]}.${parts[1]}`, Buffer.from(parts[2], 'base64url'), this.config)) {
return { allowed: false, reasonCode: 'auth_signature_invalid' };
}
const issuer = stringClaim(payload.iss);
const audience = audienceClaim(payload.aud);
const subject = stringClaim(payload.sub);
const expiresAt = numericClaim(payload.exp);
const notBefore = numericClaim(payload.nbf);
const issuedAt = numericClaim(payload.iat);
const skew = this.config.clockSkewSeconds;
if (!issuer || issuer !== this.config.issuer) return { allowed: false, reasonCode: 'auth_issuer_invalid' };
if (!this.config.audience || !audience.includes(this.config.audience)) return { allowed: false, reasonCode: 'auth_audience_invalid' };
if (!subject) return { allowed: false, reasonCode: 'auth_subject_required' };
if (!expiresAt || nowSeconds - skew >= expiresAt) return { allowed: false, reasonCode: 'auth_token_expired' };
if (notBefore !== undefined && nowSeconds + skew < notBefore) return { allowed: false, reasonCode: 'auth_token_not_yet_valid' };
if (issuedAt !== undefined && issuedAt > nowSeconds + skew) return { allowed: false, reasonCode: 'auth_issued_at_invalid' };
const roles = stringListClaim(payload[this.config.roleClaim]);
const rawTenant = stringClaim(payload[this.config.tenantClaim]);
const rawProject = stringClaim(payload[this.config.projectClaim]);
if ((rawTenant && safeScope(rawTenant, '') === '') || (rawProject && safeScope(rawProject, '') === '')) {
return { allowed: false, reasonCode: 'auth_scope_invalid' };
}
const tenant = safeScope(rawTenant, 'default');
const project = safeScope(rawProject, 'default');
return {
allowed: true,
reasonCode: 'auth_verified',
claims: { subject, issuer, audience, expiresAt, issuedAt, roles, tenant, project, authenticationMethod: 'jwt' },
};
} catch {
return { allowed: false, reasonCode: 'auth_token_malformed' };
}
}
}
export function loadAuthConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneAuthConfig {
const profile = env.CASAN_CP_STRICT === '1' ? 'strict' : (env.CASAN_PROFILE || 'development').toLowerCase();
const mode = (env.CASAN_CP_AUTH_MODE || 'local').toLowerCase() as AuthMode;
const bind = env.CP_BIND || '127.0.0.1';
const publicKeyPath = env.CASAN_CP_JWT_PUBLIC_KEY_FILE;
const publicKey = publicKeyPath && existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8') : undefined;
const skew = Number(env.CASAN_CP_JWT_CLOCK_SKEW_SECONDS ?? 60);
return {
mode,
profile,
bind,
issuer: env.CASAN_CP_JWT_ISSUER,
audience: env.CASAN_CP_JWT_AUDIENCE,
algorithm: publicKey ? 'RS256' : env.CASAN_CP_JWT_HS256_SECRET ? 'HS256' : undefined,
publicKey,
hmacSecret: env.CASAN_CP_JWT_HS256_SECRET,
clockSkewSeconds: Number.isFinite(skew) ? skew : -1,
roleClaim: env.CASAN_CP_JWT_ROLE_CLAIM || 'groups',
tenantClaim: env.CASAN_CP_JWT_TENANT_CLAIM || 'casan_tenant',
projectClaim: env.CASAN_CP_JWT_PROJECT_CLAIM || 'casan_project',
};
}
export function validateAuthConfig(config: ControlPlaneAuthConfig): string[] {
const errors: string[] = [];
const production = config.profile === 'prod' || config.profile === 'production' || config.profile === 'strict';
const nonLoopback = !['127.0.0.1', 'localhost', '::1'].includes(config.bind);
if (!['local', 'jwt'].includes(config.mode)) errors.push('auth_mode_invalid');
if ((production || nonLoopback) && config.mode !== 'jwt') errors.push('verified_identity_required');
if (config.clockSkewSeconds < 0 || config.clockSkewSeconds > 300) errors.push('auth_clock_skew_invalid');
if (config.mode === 'jwt') {
if (!config.issuer) errors.push('auth_issuer_required');
if (!config.audience) errors.push('auth_audience_required');
if (!config.algorithm) errors.push('auth_verification_key_required');
if (production && config.algorithm !== 'RS256') errors.push('auth_asymmetric_key_required_in_production');
if (config.algorithm === 'RS256') {
try {
if (!config.publicKey || createPublicKey(config.publicKey).asymmetricKeyType !== 'rsa') {
errors.push('auth_rsa_public_key_invalid');
}
} catch {
errors.push('auth_rsa_public_key_invalid');
}
}
if (config.algorithm === 'HS256' && (!config.hmacSecret || Buffer.byteLength(config.hmacSecret) < 32)) {
errors.push('auth_hmac_secret_too_short');
}
}
return errors;
}
function auditAuthentication(decision: AuthenticationDecision, request: Request): void {
const stateRoot = process.env.CASAN_STATE_ROOT || join(APP_ROOT, '.specify');
const path = join(stateRoot, 'logs', 'auth', 'decisions.jsonl');
mkdirSync(dirname(path), { recursive: true });
const record = {
schema_version: '1.0.0', category: 'runtime_control', policy_id: 'casan.control-plane.authentication',
timestamp: new Date().toISOString(), decision: decision.allowed ? 'allow' : 'deny', reason_code: decision.reasonCode,
actor: decision.claims?.subject, issuer: decision.claims?.issuer, tenant: decision.claims?.tenant,
project: decision.claims?.project, method: decision.claims?.authenticationMethod,
request: { method: request.method, path: request.path },
};
const fd = openSync(path, 'a', 0o600);
try {
writeSync(fd, `${JSON.stringify(record)}\n`);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function overwriteVerifiedHeaders(request: Request, claims: VerifiedClaims): void {
for (const header of IDENTITY_HEADERS) delete request.headers[header];
const input: VerifiedClaimInput = {
subject: claims.subject,
issuer: claims.issuer,
roles: claims.roles,
tenant: claims.tenant,
project: claims.project,
};
const actor = actorFromVerifiedClaims(input);
request.headers['x-casan-actor'] = actor.actor;
request.headers['x-casan-role'] = actor.role;
request.headers['x-casan-project'] = actor.project;
request.headers['x-casan-tenant'] = actor.tenant;
request.headers['x-casan-identity-verified'] = '1';
request.headers['x-casan-identity-issuer'] = claims.issuer;
}
export function createAuthMiddleware(config: ControlPlaneAuthConfig) {
const provider = config.mode === 'jwt' ? new JwtAuthProvider(config) : undefined;
return (request: Request, response: Response, next: NextFunction): void => {
if (!provider) {
request.headers['x-casan-identity-verified'] = 'local-development-only';
next();
return;
}
const headers = request.headers as Record<string, string | string[] | undefined>;
const decision = provider.authenticate(headers);
auditAuthentication(decision, request);
if (!decision.allowed || !decision.claims) {
response.status(401).json({ success: false, error: { code: decision.reasonCode, message: 'Authentication failed' } });
return;
}
overwriteVerifiedHeaders(request, decision.claims);
next();
};
}
export function tokenFingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex').slice(0, 12);
}
@@ -3,23 +3,28 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module.js';
import { APP_ROOT } from './common/app-root.js';
import { createAuthMiddleware, loadAuthConfig, validateAuthConfig } from './common/auth-provider.js';
// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
// proxy is explicitly configured to overwrite identity headers.
// Ops Console API (Plan-13). Binds loopback by default. Networked and production
// deployments require in-process cryptographic identity verification.
async function bootstrap() {
const authConfig = loadAuthConfig();
const authErrors = validateAuthConfig(authConfig);
if (authErrors.length > 0) {
console.error(`CP_AUTH_CONFIGURATION_INVALID reasons=${authErrors.join(',')}`);
process.exit(2);
}
const app = await NestFactory.create(AppModule, { cors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.use(createAuthMiddleware(authConfig));
const port = Number(process.env.CP_PORT ?? 3010);
let host = process.env.CP_BIND ?? '127.0.0.1';
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
// The console must not expose telemetry/management off-loopback without TLS/OIDC
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
if (strict && host !== '127.0.0.1' && host !== 'localhost' && authConfig.mode !== 'jwt') {
// Networked production requires in-process cryptographic verification.
// eslint-disable-next-line no-console
console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`);
console.error(`CP_REFUSE_NONLOOPBACK host=${host} reason=verified_identity_required`);
process.exit(2);
}
@@ -298,6 +298,8 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep
return {
schema_version: 1,
category: 'report_dimension',
dimension_id: 'ReportDimension.H6',
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
harness: 'H6',
title: 'H6 · AgentOps Report',
@@ -36,6 +36,8 @@ export interface HarnessReportEvidenceSource extends SourceFreshness {
export interface HarnessReport<TSummary, TDetails> {
schema_version: 1;
category: 'report_dimension';
dimension_id: `ReportDimension.${HarnessReportId}`;
report_id: string;
harness: HarnessReportId;
title: string;
@@ -71,6 +73,8 @@ export interface HarnessReportCatalogEntry {
title: string;
description: string;
contract_version: 1;
category: 'report_dimension';
dimension_id: `ReportDimension.${HarnessReportId}`;
endpoint: string;
availability: 'implemented' | 'contract_ready';
}
@@ -78,6 +82,8 @@ export interface HarnessReportCatalogEntry {
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
...definition,
contract_version: 1,
category: 'report_dimension',
dimension_id: `ReportDimension.${definition.id}`,
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
availability: definition.id === 'H6' ? 'implemented' : 'contract_ready',
}));
@@ -29,3 +29,17 @@ test('auth context fails closed to viewer for unknown role claim', () => {
});
assert.equal(actor.role, 'viewer');
});
test('JWT mode rejects direct spoofed identity headers without middleware verification', () => {
const prior = process.env.CASAN_CP_AUTH_MODE;
process.env.CASAN_CP_AUTH_MODE = 'jwt';
try {
assert.throws(() => actorFromHeaders({
'x-casan-actor': 'attacker',
'x-casan-role': 'org-admin',
}), /AUTH_VERIFIED_IDENTITY_REQUIRED/);
} finally {
if (prior === undefined) delete process.env.CASAN_CP_AUTH_MODE;
else process.env.CASAN_CP_AUTH_MODE = prior;
}
});
@@ -0,0 +1,110 @@
import { createHmac, createSign, generateKeyPairSync } from 'node:crypto';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
JwtAuthProvider,
validateAuthConfig,
type ControlPlaneAuthConfig,
} from '../src/common/auth-provider.js';
const secret = 'test-only-secret-with-sufficient-length';
const baseConfig: ControlPlaneAuthConfig = {
mode: 'jwt',
profile: 'test',
bind: '0.0.0.0',
issuer: 'https://issuer.test',
audience: 'casan-control-plane',
algorithm: 'HS256',
hmacSecret: secret,
clockSkewSeconds: 30,
roleClaim: 'groups',
tenantClaim: 'casan_tenant',
projectClaim: 'casan_project',
};
function token(payload: Record<string, unknown>, signingSecret = secret): string {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', signingSecret).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
function rsToken(payload: Record<string, unknown>, privateKey: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${body}`);
signer.end();
return `${header}.${body}.${signer.sign(privateKey).toString('base64url')}`;
}
function claims(now: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
iss: baseConfig.issuer,
aud: baseConfig.audience,
sub: 'verified-user',
exp: now + 300,
iat: now,
groups: ['project-admin'],
casan_tenant: 'tenant-a',
casan_project: 'project-a',
...overrides,
};
}
test('JWT provider verifies claims and ignores spoofed forwarded identity headers', () => {
const now = 1_800_000_000;
const provider = new JwtAuthProvider(baseConfig);
const decision = provider.authenticate({
authorization: `Bearer ${token(claims(now))}`,
'x-forwarded-user': 'attacker',
'x-casan-role': 'org-admin',
'x-casan-tenant': 'victim',
}, now);
assert.equal(decision.allowed, true);
assert.equal(decision.claims?.subject, 'verified-user');
assert.equal(decision.claims?.tenant, 'tenant-a');
assert.deepEqual(decision.claims?.roles, ['project-admin']);
});
test('JWT provider rejects missing, invalid, expired, wrong-audience and wrong-issuer tokens', () => {
const now = 1_800_000_000;
const provider = new JwtAuthProvider(baseConfig);
assert.equal(provider.authenticate({}, now).reasonCode, 'auth_bearer_token_required');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now), 'wrong-secret')}` }, now).reasonCode, 'auth_signature_invalid');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { exp: now - 31 }))}` }, now).reasonCode, 'auth_token_expired');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { aud: 'wrong' }))}` }, now).reasonCode, 'auth_audience_invalid');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { iss: 'https://wrong.test' }))}` }, now).reasonCode, 'auth_issuer_invalid');
});
test('production-compatible RS256 verification accepts a valid asymmetric token', () => {
const now = 1_800_000_000;
const keys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const provider = new JwtAuthProvider({
...baseConfig,
profile: 'production',
algorithm: 'RS256',
publicKey: keys.publicKey,
hmacSecret: undefined,
});
const decision = provider.authenticate({ authorization: `Bearer ${rsToken(claims(now), keys.privateKey)}` }, now);
assert.equal(decision.allowed, true);
assert.equal(decision.claims?.subject, 'verified-user');
});
test('production and non-loopback startup refuse local or symmetric identity modes', () => {
assert.deepEqual(
validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'production' }),
['verified_identity_required'],
);
assert.ok(validateAuthConfig({ ...baseConfig, profile: 'production' }).includes('auth_asymmetric_key_required_in_production'));
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'development' }).includes('verified_identity_required'));
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'strict', bind: '127.0.0.1' }).includes('verified_identity_required'));
assert.ok(validateAuthConfig({
...baseConfig, profile: 'production', algorithm: 'RS256', publicKey: 'not-a-public-key', hmacSecret: undefined,
}).includes('auth_rsa_public_key_invalid'));
});