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

View File

@@ -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);