feat(phase-2): workstream 5 — OPA Policy Engine

- policies/authz.rego: Rego policy with path normalisation and scope enforcement
- policies/data/scopes.json: all 13 endpoint → scope mappings
- src/middleware/opa.ts: OpaMiddleware with Wasm primary path + scopes.json fallback;
  exports createOpaMiddleware() and reloadOpaPolicy() for SIGHUP hot-reload
- All four route files: opaMiddleware wired after authMiddleware
- AuditController, OAuth2Service: manual scope checks removed (now centralised in OPA)
- src/server.ts: SIGHUP handler calls reloadOpaPolicy()
- docs/devops/environment-variables.md: POLICY_DIR documented
- 38 new tests; 302/302 passing; opa.ts coverage 98.66% statements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 23:02:11 +00:00
parent 8cdab72fea
commit 7328a61c44
18 changed files with 1108 additions and 62 deletions

279
src/middleware/opa.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* OPA (Open Policy Agent) authorization middleware for SentryAgent.ai AgentIdP.
*
* Primary path — Wasm bundle: When `${POLICY_DIR}/authz.wasm` exists, the policy is
* evaluated using `@open-policy-agent/opa-wasm`. The bundle is loaded once at startup
* and can be hot-reloaded via `reloadOpaPolicy()`.
*
* Fallback path — scopes.json: When no Wasm bundle is present (dev/test), the policy
* is evaluated entirely in TypeScript by reading `${POLICY_DIR}/data/scopes.json` and
* applying the same normalisation + scope-intersection logic as `policies/authz.rego`.
*/
import fs from 'fs';
import path from 'path';
import { RequestHandler } from 'express';
import { loadPolicy, LoadedPolicy } from '@open-policy-agent/opa-wasm';
import { AuthorizationError } from '../utils/errors.js';
// ────────────────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────────────────
/** Input shape sent to the OPA policy for every authorization check. */
interface OpaInput {
/** HTTP method in uppercase — e.g. "GET". */
method: string;
/** Full request path — e.g. "/api/v1/agents/abc-123". */
path: string;
/** Scopes extracted from the caller's JWT — e.g. ["agents:read"]. */
scopes: string[];
}
/** Expected shape of the OPA Wasm result set entry. */
interface OpaResultEntry {
result?: {
allow?: boolean;
};
}
/**
* Shape of `policies/data/scopes.json`.
* Keys are `"METHOD:/normalised/path"`, values are arrays of required scope strings.
*/
interface ScopesJson {
endpoint_permissions: Record<string, string[]>;
}
// ────────────────────────────────────────────────────────────────────────────
// Module-level singletons (replaced on hot-reload)
// ────────────────────────────────────────────────────────────────────────────
/**
* Resolved base directory for policy files.
* Defaults to `<cwd>/policies`; override via `POLICY_DIR` environment variable.
*/
const POLICY_DIR: string =
process.env['POLICY_DIR'] ?? path.resolve(process.cwd(), 'policies');
const WASM_PATH = path.join(POLICY_DIR, 'authz.wasm');
const SCOPES_PATH = path.join(POLICY_DIR, 'data', 'scopes.json');
/** Active Wasm policy instance — null when running in fallback (scopes.json) mode. */
let wasmPolicy: LoadedPolicy | null = null;
/** Fallback scope map — null when running in Wasm mode. */
let scopesMap: Record<string, string[]> | null = null;
// ────────────────────────────────────────────────────────────────────────────
// Path normalisation (mirrors `normalise_path` in authz.rego)
// ────────────────────────────────────────────────────────────────────────────
/**
* Normalises a concrete request path to the pattern key used in `scopes.json`.
* The priority ordering mirrors the longest-match ordering in `authz.rego`.
*
* @param requestPath - Raw request path from `req.path`.
* @returns Normalised pattern string, or the original path if no pattern matches.
*/
function normalisePath(requestPath: string): string {
// /api/v1/agents/:id/credentials/:credId/rotate (longest — checked first)
if (/^\/api\/v1\/agents\/[^/]+\/credentials\/[^/]+\/rotate$/.test(requestPath)) {
return '/api/v1/agents/:id/credentials/:credId/rotate';
}
// /api/v1/agents/:id/credentials/:credId
if (/^\/api\/v1\/agents\/[^/]+\/credentials\/[^/]+$/.test(requestPath)) {
return '/api/v1/agents/:id/credentials/:credId';
}
// /api/v1/agents/:id/credentials
if (/^\/api\/v1\/agents\/[^/]+\/credentials$/.test(requestPath)) {
return '/api/v1/agents/:id/credentials';
}
// /api/v1/agents/:id
if (/^\/api\/v1\/agents\/[^/]+$/.test(requestPath)) {
return '/api/v1/agents/:id';
}
// Static paths — returned as-is
if (
requestPath === '/api/v1/agents' ||
requestPath === '/api/v1/token/introspect' ||
requestPath === '/api/v1/token/revoke' ||
requestPath === '/api/v1/audit'
) {
return requestPath;
}
// /api/v1/audit/:id
if (/^\/api\/v1\/audit\/[^/]+$/.test(requestPath)) {
return '/api/v1/audit/:id';
}
// Unknown path — return as-is; the policy will produce no match → deny
return requestPath;
}
// ────────────────────────────────────────────────────────────────────────────
// Policy loading helpers
// ────────────────────────────────────────────────────────────────────────────
/**
* Attempts to load the Wasm bundle from disk.
* Returns `true` if successful, `false` if the file does not exist.
*
* @returns Whether the Wasm bundle was loaded.
*/
async function loadWasmPolicy(): Promise<boolean> {
if (!fs.existsSync(WASM_PATH)) {
return false;
}
const wasmBuffer = fs.readFileSync(WASM_PATH);
const loaded = await loadPolicy(wasmBuffer);
// Load the scopes data so the Wasm policy has access to endpoint_permissions
if (fs.existsSync(SCOPES_PATH)) {
const raw = fs.readFileSync(SCOPES_PATH, 'utf-8');
const parsed = JSON.parse(raw) as ScopesJson;
loaded.setData(parsed);
}
wasmPolicy = loaded;
scopesMap = null;
return true;
}
/**
* Loads the fallback `scopes.json` into memory.
* Called when no Wasm bundle is present.
*/
function loadScopesFallback(): void {
const raw = fs.readFileSync(SCOPES_PATH, 'utf-8');
const parsed = JSON.parse(raw) as ScopesJson;
scopesMap = parsed.endpoint_permissions;
wasmPolicy = null;
}
// ────────────────────────────────────────────────────────────────────────────
// Authorization evaluation
// ────────────────────────────────────────────────────────────────────────────
/**
* Evaluates the OPA policy against the given input.
*
* Uses the Wasm bundle when available; falls back to TypeScript scope-map logic.
*
* @param input - The authorization input (method, path, scopes).
* @returns `true` if the policy allows the request; `false` otherwise.
*/
function evaluate(input: OpaInput): boolean {
if (wasmPolicy !== null) {
// Wasm path: evaluate and extract `allow` from the result set
const resultSet = wasmPolicy.evaluate(input) as OpaResultEntry[];
if (!Array.isArray(resultSet) || resultSet.length === 0) {
return false;
}
return resultSet[0]?.result?.allow === true;
}
if (scopesMap !== null) {
// Fallback path: replicate authz.rego logic in TypeScript
const normPath = normalisePath(input.path);
const lookupKey = `${input.method}:${normPath}`;
const required = scopesMap[lookupKey];
// If no entry exists for this endpoint the policy denies
if (!required) {
return false;
}
return required.every((s) => input.scopes.includes(s));
}
// Neither policy loaded — deny by default (fail-closed)
return false;
}
// ────────────────────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────────────────────
/**
* Creates the OPA authorization middleware.
*
* Call once at application startup. The returned `RequestHandler` can be wired
* into any Express router after the authentication middleware.
*
* Startup order:
* 1. Try to load `${POLICY_DIR}/authz.wasm` (primary).
* 2. If the Wasm bundle is absent, load `${POLICY_DIR}/data/scopes.json` (fallback).
*
* @returns Promise resolving to an Express `RequestHandler` for OPA authorization.
* @throws Error if neither the Wasm bundle nor `scopes.json` can be loaded.
*/
export async function createOpaMiddleware(): Promise<RequestHandler> {
const wasmLoaded = await loadWasmPolicy();
if (wasmLoaded) {
console.log('[AgentIdP] OPA policy engine: Wasm mode — loaded', WASM_PATH);
} else {
loadScopesFallback();
console.log('[AgentIdP] OPA policy engine: fallback mode — loaded', SCOPES_PATH);
}
/**
* Express middleware that authorises the current request against the OPA policy.
*
* Prerequisites: `authMiddleware` must have run first to populate `req.user`.
*
* @param req - Express request (must have `req.user` populated by auth middleware).
* @param _res - Express response (unused).
* @param next - Express next function.
*/
const handler: RequestHandler = (req, _res, next): void => {
try {
if (!req.user) {
// Auth middleware should have already rejected unauthenticated requests;
// this is a safeguard against misconfigured middleware ordering.
next(new AuthorizationError('Request is not authenticated.'));
return;
}
const input: OpaInput = {
method: req.method,
// Use baseUrl + path to reconstruct the full path (req.path is relative
// to the router's mount point; OPA policy patterns match full paths).
path: req.baseUrl + req.path,
scopes: req.user.scope.split(' '),
};
if (!evaluate(input)) {
next(new AuthorizationError());
return;
}
next();
} catch (err) {
next(err);
}
};
return handler;
}
/**
* Reloads the OPA policy from disk without restarting the server.
*
* Intended to be called from a SIGHUP handler. The reload strategy mirrors
* startup: Wasm bundle is preferred; `scopes.json` is used as fallback.
*
* @returns Promise that resolves when the policy has been reloaded.
* @throws Error if the reload fails (e.g. file is missing or malformed).
*/
export async function reloadOpaPolicy(): Promise<void> {
const wasmLoaded = await loadWasmPolicy();
if (wasmLoaded) {
console.log('[AgentIdP] OPA policy reloaded: Wasm mode —', WASM_PATH);
} else {
loadScopesFallback();
console.log('[AgentIdP] OPA policy reloaded: fallback mode —', SCOPES_PATH);
}
}