feat(casan): establish assurance kernel and harden control plane
This commit is contained in:
@@ -101,8 +101,10 @@ Goal workspace context:
|
||||
a different actor. The executor verifies the artifact hash, applies it, runs fixed
|
||||
project build/test commands, and reverses the patch if verification fails.
|
||||
|
||||
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
|
||||
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
|
||||
In explicit loopback development mode only, local management headers are
|
||||
`x-casan-actor`, `x-casan-role`, `x-casan-project`, and `x-casan-tenant`.
|
||||
Missing role defaults to `viewer`, so writes fail closed. JWT mode discards
|
||||
these caller assertions and derives them only from verified token claims.
|
||||
|
||||
Kill-switch management:
|
||||
|
||||
@@ -169,12 +171,19 @@ App root + telemetry paths resolve via the same marker walk-up as `casan-paths.s
|
||||
compatibility aliases. Freshness is calculated independently from each file's mtime using
|
||||
`CASAN_DASHBOARD_STALE_S` (default `3600`).
|
||||
|
||||
## Security posture (MVP)
|
||||
Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` /
|
||||
`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse
|
||||
proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness
|
||||
`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through
|
||||
the same harness engine.
|
||||
## Security posture
|
||||
Binds `127.0.0.1` in explicit local development mode. Every non-loopback bind,
|
||||
and every production profile, requires `CASAN_CP_AUTH_MODE=jwt`. Production
|
||||
requires an RS256 public key plus configured issuer and audience; missing or
|
||||
invalid configuration refuses startup. The API verifies signature, expiry,
|
||||
issuer, audience, `nbf`/`iat`, and bounded clock skew in-process, then maps
|
||||
verified group claims through `rbac-check.py`. Arbitrary `X-CASAN-*` and
|
||||
forwarded-user headers are not an authentication mechanism.
|
||||
|
||||
Production variables are documented in `infra/production/runtime.env.example`.
|
||||
The packaged boundary is oauth2-proxy → Nginx header stripping/bearer forwarding
|
||||
→ API cryptographic verification. The current provider uses a mounted RS256
|
||||
public key; automated JWKS discovery/rotation remains future work.
|
||||
|
||||
## Test
|
||||
```bash
|
||||
@@ -189,8 +198,8 @@ bash packages/casan-control-panel/scripts/local-prod-smoke.sh
|
||||
|
||||
The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and
|
||||
`nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`,
|
||||
overwrites browser-supplied `X-CASAN-*` headers, and passes IdP group claims to the API for
|
||||
RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
|
||||
removes browser-supplied identity headers, and forwards the signed bearer token for API
|
||||
verification. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
|
||||
expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also
|
||||
asserts the Command Center returns all nine widgets with provenance envelopes and invokes
|
||||
`managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -17,6 +17,14 @@ if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
|
||||
-subj "/CN=localhost" \
|
||||
-days 1 >/dev/null 2>&1
|
||||
fi
|
||||
IDP_PRIVATE="$ROOT/tmp/control-panel-local/idp-private.pem"
|
||||
IDP_PUBLIC="$ROOT/tmp/control-panel-local/idp-public.pem"
|
||||
if [[ ! -f "$IDP_PRIVATE" || ! -f "$IDP_PUBLIC" ]]; then
|
||||
openssl genrsa -out "$IDP_PRIVATE" 2048 >/dev/null 2>&1
|
||||
openssl rsa -in "$IDP_PRIVATE" -pubout -out "$IDP_PUBLIC" >/dev/null 2>&1
|
||||
chmod 0600 "$IDP_PRIVATE"
|
||||
chmod 0644 "$IDP_PUBLIC"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
Reference in New Issue
Block a user