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:
14
src/app.ts
14
src/app.ts
@@ -33,6 +33,7 @@ import { createCredentialsRouter } from './routes/credentials.js';
|
||||
import { createAuditRouter } from './routes/audit.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { RedisClientType } from 'redis';
|
||||
|
||||
@@ -120,6 +121,11 @@ export async function createApp(): Promise<Application> {
|
||||
vaultClient,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// OPA authorization middleware (created once — shared across all routers)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const opaMiddleware = await createOpaMiddleware();
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Controller layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -133,13 +139,13 @@ export async function createApp(): Promise<Application> {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController));
|
||||
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware));
|
||||
app.use(
|
||||
`${API_BASE}/agents/:agentId/credentials`,
|
||||
createCredentialsRouter(credentialController),
|
||||
createCredentialsRouter(credentialController, opaMiddleware),
|
||||
);
|
||||
app.use(`${API_BASE}/token`, createTokenRouter(tokenController));
|
||||
app.use(`${API_BASE}/audit`, createAuditRouter(auditController));
|
||||
app.use(`${API_BASE}/token`, createTokenRouter(tokenController, opaMiddleware));
|
||||
app.use(`${API_BASE}/audit`, createAuditRouter(auditController, opaMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Global error handler (must be last)
|
||||
|
||||
@@ -9,13 +9,12 @@ import { auditQuerySchema } from '../utils/validators.js';
|
||||
import {
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
InsufficientScopeError,
|
||||
} from '../utils/errors.js';
|
||||
import { IAuditListFilters } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Controller for the Audit Log endpoints.
|
||||
* Enforces `audit:read` scope on all handlers.
|
||||
* Authorization is enforced by OPA middleware — no per-handler scope checks required.
|
||||
*/
|
||||
export class AuditController {
|
||||
/**
|
||||
@@ -37,12 +36,6 @@ export class AuditController {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// Enforce audit:read scope
|
||||
const scopes = req.user.scope.split(' ');
|
||||
if (!scopes.includes('audit:read')) {
|
||||
throw new InsufficientScopeError('audit:read');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { error, value } = auditQuerySchema.validate(req.query, { abortEarly: false });
|
||||
if (error) {
|
||||
@@ -84,12 +77,6 @@ export class AuditController {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// Enforce audit:read scope
|
||||
const scopes = req.user.scope.split(' ');
|
||||
if (!scopes.includes('audit:read')) {
|
||||
throw new InsufficientScopeError('audit:read');
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
const event = await this.auditService.getEventById(eventId);
|
||||
res.status(200).json(event);
|
||||
|
||||
279
src/middleware/opa.ts
Normal file
279
src/middleware/opa.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Agent Registry routes for SentryAgent.ai AgentIdP.
|
||||
* Wires AgentController handlers to Express paths with auth and rateLimit middleware.
|
||||
* Wires AgentController handlers to Express paths with auth, OPA, and rateLimit middleware.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { AgentController } from '../controllers/AgentController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
@@ -13,12 +13,14 @@ import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
* Creates and returns the Express router for agent registry endpoints.
|
||||
*
|
||||
* @param agentController - The agent controller instance.
|
||||
* @param opaMiddleware - The OPA authorization middleware created at startup.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAgentsRouter(agentController: AgentController): Router {
|
||||
export function createAgentsRouter(agentController: AgentController, opaMiddleware: RequestHandler): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(opaMiddleware);
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// POST /agents — Register a new agent
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Audit Log routes for SentryAgent.ai AgentIdP.
|
||||
* All routes require Bearer auth and are rate-limited.
|
||||
* All routes require Bearer auth, OPA authorization, and are rate-limited.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { AuditController } from '../controllers/AuditController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
@@ -13,12 +13,14 @@ import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
* Creates and returns the Express router for audit log endpoints.
|
||||
*
|
||||
* @param auditController - The audit controller instance.
|
||||
* @param opaMiddleware - The OPA authorization middleware created at startup.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAuditRouter(auditController: AuditController): Router {
|
||||
export function createAuditRouter(auditController: AuditController, opaMiddleware: RequestHandler): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(opaMiddleware);
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// GET /audit — Query audit log
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Credential Management routes for SentryAgent.ai AgentIdP.
|
||||
* All routes are under /agents/:agentId/credentials with auth and rateLimit middleware.
|
||||
* All routes are under /agents/:agentId/credentials with auth, OPA, and rateLimit middleware.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { CredentialController } from '../controllers/CredentialController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
@@ -14,12 +14,14 @@ import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
* This router is mounted at /agents — the :agentId param is part of the path.
|
||||
*
|
||||
* @param credentialController - The credential controller instance.
|
||||
* @param opaMiddleware - The OPA authorization middleware created at startup.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createCredentialsRouter(credentialController: CredentialController): Router {
|
||||
export function createCredentialsRouter(credentialController: CredentialController, opaMiddleware: RequestHandler): Router {
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
router.use(asyncHandler(authMiddleware));
|
||||
router.use(opaMiddleware);
|
||||
router.use(asyncHandler(rateLimitMiddleware));
|
||||
|
||||
// POST /agents/:agentId/credentials — Generate new credentials
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* OAuth 2.0 Token routes for SentryAgent.ai AgentIdP.
|
||||
* POST /token uses no Bearer auth (credentials are in the body).
|
||||
* POST /token/introspect and POST /token/revoke require Bearer auth.
|
||||
* POST /token/introspect and POST /token/revoke require Bearer auth and OPA authorization.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { TokenController } from '../controllers/TokenController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
|
||||
@@ -14,26 +14,29 @@ import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
* Creates and returns the Express router for token endpoints.
|
||||
*
|
||||
* @param tokenController - The token controller instance.
|
||||
* @param opaMiddleware - The OPA authorization middleware created at startup.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createTokenRouter(tokenController: TokenController): Router {
|
||||
export function createTokenRouter(tokenController: TokenController, opaMiddleware: RequestHandler): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /token — Issue token (no auth — credentials in body or Basic header)
|
||||
router.post('/', asyncHandler(rateLimitMiddleware), asyncHandler(tokenController.issueToken.bind(tokenController)));
|
||||
|
||||
// POST /token/introspect — Introspect token (requires Bearer auth)
|
||||
// POST /token/introspect — Introspect token (requires Bearer auth + OPA)
|
||||
router.post(
|
||||
'/introspect',
|
||||
asyncHandler(authMiddleware),
|
||||
opaMiddleware,
|
||||
asyncHandler(rateLimitMiddleware),
|
||||
asyncHandler(tokenController.introspectToken.bind(tokenController)),
|
||||
);
|
||||
|
||||
// POST /token/revoke — Revoke token (requires Bearer auth)
|
||||
// POST /token/revoke — Revoke token (requires Bearer auth + OPA)
|
||||
router.post(
|
||||
'/revoke',
|
||||
asyncHandler(authMiddleware),
|
||||
opaMiddleware,
|
||||
asyncHandler(rateLimitMiddleware),
|
||||
asyncHandler(tokenController.revokeToken.bind(tokenController)),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import { createApp } from './app.js';
|
||||
import { reloadOpaPolicy } from './middleware/opa.js';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
|
||||
|
||||
@@ -37,6 +38,14 @@ async function main(): Promise<void> {
|
||||
process.on('SIGINT', () => {
|
||||
shutdown();
|
||||
});
|
||||
|
||||
// Hot-reload OPA policy without restarting the server
|
||||
process.on('SIGHUP', () => {
|
||||
reloadOpaPolicy().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AgentIdP] Failed to reload OPA policy:', err);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start server:', err);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
InsufficientScopeError,
|
||||
} from '../utils/errors.js';
|
||||
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../utils/jwt.js';
|
||||
import { verifySecret } from '../utils/crypto.js';
|
||||
@@ -214,14 +213,13 @@ export class OAuth2Service {
|
||||
/**
|
||||
* Introspects a token per RFC 7662.
|
||||
* Always returns 200; check the `active` field for validity.
|
||||
* Requires the caller to hold a token with `tokens:read` scope.
|
||||
* Scope enforcement (`tokens:read`) is handled upstream by OPA middleware.
|
||||
*
|
||||
* @param token - The JWT string to introspect.
|
||||
* @param callerPayload - The decoded payload of the calling agent's token (for scope check).
|
||||
* @param callerPayload - The decoded payload of the calling agent's token.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The introspection response.
|
||||
* @throws InsufficientScopeError if the caller lacks `tokens:read` scope.
|
||||
*/
|
||||
async introspectToken(
|
||||
token: string,
|
||||
@@ -229,12 +227,6 @@ export class OAuth2Service {
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IIntrospectResponse> {
|
||||
// Check caller has tokens:read scope
|
||||
const callerScopes = callerPayload.scope.split(' ');
|
||||
if (!callerScopes.includes('tokens:read')) {
|
||||
throw new InsufficientScopeError('tokens:read');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifyToken(token, this.publicKey);
|
||||
const revoked = await this.tokenRepository.isRevoked(payload.jti);
|
||||
|
||||
Reference in New Issue
Block a user