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

@@ -10,7 +10,6 @@ import { ITokenPayload, IAuditEvent } from '../../../src/types/index';
import {
ValidationError,
AuthenticationError,
InsufficientScopeError,
AuditEventNotFoundError,
} from '../../../src/utils/errors';
@@ -103,13 +102,18 @@ describe('AuditController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(InsufficientScopeError) when scope does not include audit:read', async () => {
it('should call auditService.queryEvents regardless of scope (scope enforced by OPA middleware)', async () => {
// Scope enforcement has been moved to OpaMiddleware; the controller delegates
// to the service for all authenticated requests that reach it.
const { req, res, next } = buildMocks('agents:read');
req.query = {};
const emptyResponse = { data: [], total: 0, page: 1, limit: 50 };
auditService.queryEvents.mockResolvedValue(emptyResponse);
await controller.queryAuditLog(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(InsufficientScopeError));
expect(auditService.queryEvents).not.toHaveBeenCalled();
expect(auditService.queryEvents).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should call next(ValidationError) when query params are invalid', async () => {
@@ -190,14 +194,17 @@ describe('AuditController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(InsufficientScopeError) when scope does not include audit:read', async () => {
it('should call auditService.getEventById regardless of scope (scope enforced by OPA middleware)', async () => {
// Scope enforcement has been moved to OpaMiddleware; the controller delegates
// to the service for all authenticated requests that reach it.
const { req, res, next } = buildMocks('agents:read');
req.params = { eventId: MOCK_AUDIT_EVENT.eventId };
auditService.getEventById.mockResolvedValue(MOCK_AUDIT_EVENT);
await controller.getAuditEventById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(InsufficientScopeError));
expect(auditService.getEventById).not.toHaveBeenCalled();
expect(auditService.getEventById).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should forward AuditEventNotFoundError to next', async () => {