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

@@ -0,0 +1,464 @@
/**
* Unit tests for src/middleware/opa.ts
*
* All tests run in fallback mode (scopes.json).
* `fs.existsSync` is mocked to return false for the Wasm bundle path so
* the Wasm loader is bypassed and `loadScopesFallback()` is always called.
*
* POLICY_DIR is set to the real `policies/` directory in the project root so
* tests use the production `data/scopes.json` without duplicating it.
*/
import path from 'path';
import { Request, Response, NextFunction } from 'express';
import { RequestHandler } from 'express';
import { AuthorizationError } from '../../../src/utils/errors';
import { ITokenPayload } from '../../../src/types/index';
// ─── Point POLICY_DIR at the real policies directory ─────────────────────────
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
const POLICIES_DIR = path.join(PROJECT_ROOT, 'policies');
process.env['POLICY_DIR'] = POLICIES_DIR;
// ─── Mock fs.existsSync so Wasm bundle is never found ────────────────────────
// We do this BEFORE importing the module under test so the module-level
// WASM_PATH check in `loadWasmPolicy()` always returns false.
jest.mock('fs', () => {
const actual = jest.requireActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: jest.fn((filePath: unknown) => {
// Deny Wasm bundle; allow all other paths (including scopes.json)
if (typeof filePath === 'string' && filePath.endsWith('.wasm')) {
return false;
}
return actual.existsSync(filePath as string);
}),
};
});
// ─── Import the module under test AFTER mocks are in place ───────────────────
import { createOpaMiddleware, reloadOpaPolicy } from '../../../src/middleware/opa';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(scope: string): ITokenPayload {
return {
sub: 'agent-abc-123',
client_id: 'agent-abc-123',
scope,
jti: 'jti-001',
iat: 1000,
exp: 9999999999,
};
}
/**
* Builds a minimal mock Express Request for OPA middleware testing.
* The middleware uses `req.baseUrl + req.path` for the full path.
*/
function makeReq(
method: string,
baseUrl: string,
reqPath: string,
user?: ITokenPayload,
): Partial<Request> {
return {
method,
baseUrl,
path: reqPath,
user,
};
}
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('createOpaMiddleware (fallback mode)', () => {
let middleware: RequestHandler;
let next: jest.MockedFunction<NextFunction>;
beforeAll(async () => {
// Create middleware once; all tests share the same loaded scopes.json
middleware = await createOpaMiddleware();
});
beforeEach(() => {
next = jest.fn();
});
// ── Unauthenticated request ───────────────────────────────────────────────
it('should call next(AuthorizationError) with "not authenticated" when req.user is absent', () => {
const req = makeReq('GET', '/api/v1', '/agents', undefined) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledTimes(1);
const err = (next as jest.Mock).mock.calls[0][0] as AuthorizationError;
expect(err).toBeInstanceOf(AuthorizationError);
expect(err.message).toMatch(/not authenticated/i);
});
// ── agents:read endpoints ──────────────────────────────────────────────────
it('should allow GET /api/v1/agents with agents:read scope', () => {
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny GET /api/v1/agents with agents:write scope only', () => {
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:write')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should deny GET /api/v1/agents when scope list is empty', () => {
const req = makeReq('GET', '/api/v1', '/agents', makeUser('')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should allow GET /api/v1/agents/:id with agents:read scope (UUID path)', () => {
const req = makeReq(
'GET',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000',
makeUser('agents:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny GET /api/v1/agents/:id with wrong scope', () => {
const req = makeReq(
'GET',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000',
makeUser('audit:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
// ── agents:write endpoints ─────────────────────────────────────────────────
it('should allow POST /api/v1/agents with agents:write scope', () => {
const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:write')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny POST /api/v1/agents with agents:read scope only', () => {
const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should allow PATCH /api/v1/agents/:id with agents:write scope', () => {
const req = makeReq(
'PATCH',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should allow DELETE /api/v1/agents/:id with agents:write scope', () => {
const req = makeReq(
'DELETE',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
// ── credentials sub-resource endpoints ────────────────────────────────────
it('should allow GET /api/v1/agents/:id/credentials with agents:read scope', () => {
const req = makeReq(
'GET',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000/credentials',
makeUser('agents:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should allow POST /api/v1/agents/:id/credentials with agents:write scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000/credentials',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should allow POST /api/v1/agents/:id/credentials/:credId/rotate with agents:write scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny POST /api/v1/agents/:id/credentials/:credId/rotate with agents:read scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate',
makeUser('agents:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should allow DELETE /api/v1/agents/:id/credentials/:credId with agents:write scope', () => {
const req = makeReq(
'DELETE',
'/api/v1',
'/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
// ── tokens:read endpoints ──────────────────────────────────────────────────
it('should allow POST /api/v1/token/introspect with tokens:read scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/token/introspect',
makeUser('tokens:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny POST /api/v1/token/introspect with agents:read scope only', () => {
const req = makeReq(
'POST',
'/api/v1',
'/token/introspect',
makeUser('agents:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should allow POST /api/v1/token/revoke with tokens:read scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/token/revoke',
makeUser('tokens:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny POST /api/v1/token/revoke without tokens:read scope', () => {
const req = makeReq(
'POST',
'/api/v1',
'/token/revoke',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
// ── audit:read endpoints ───────────────────────────────────────────────────
it('should allow GET /api/v1/audit with audit:read scope', () => {
const req = makeReq('GET', '/api/v1', '/audit', makeUser('audit:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny GET /api/v1/audit with agents:read scope only', () => {
const req = makeReq('GET', '/api/v1', '/audit', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should allow GET /api/v1/audit/:id with audit:read scope (UUID path)', () => {
const req = makeReq(
'GET',
'/api/v1',
'/audit/550e8400-e29b-41d4-a716-446655440000',
makeUser('audit:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should deny GET /api/v1/audit/:id without audit:read scope', () => {
const req = makeReq(
'GET',
'/api/v1',
'/audit/550e8400-e29b-41d4-a716-446655440000',
makeUser('tokens:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
// ── Path normalisation ────────────────────────────────────────────────────
it('should normalise UUID agent path correctly (longest segment first)', () => {
// Full rotate path with real UUIDs — must hit the :credId/rotate rule
const req = makeReq(
'POST',
'/api/v1',
'/agents/a1b2c3d4-e5f6-4000-8000-ef1234567890/credentials/b2c3d4e5-f6a7-4000-8000-fa2345678901/rotate',
makeUser('agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should normalise path with non-UUID segment identifiers', () => {
// Non-UUID IDs are still matched by the regex (any non-slash characters)
const req = makeReq(
'GET',
'/api/v1',
'/agents/my-agent-slug',
makeUser('agents:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
// ── Unknown / unmapped paths ──────────────────────────────────────────────
it('should deny (fail-closed) when path has no matching entry in scopes.json', () => {
const req = makeReq(
'GET',
'/api/v1',
'/unknown/resource',
makeUser('agents:read agents:write tokens:read audit:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should deny (fail-closed) for a valid path with wrong HTTP method', () => {
// PUT is not in scopes.json for any endpoint
const req = makeReq(
'PUT',
'/api/v1',
'/agents',
makeUser('agents:read agents:write'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
// ── Multi-scope token ─────────────────────────────────────────────────────
it('should allow access when token has multiple scopes including the required one', () => {
const req = makeReq(
'GET',
'/api/v1',
'/audit',
makeUser('agents:read tokens:read audit:read'),
) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
});
// ─── reloadOpaPolicy ──────────────────────────────────────────────────────────
describe('reloadOpaPolicy()', () => {
it('should reload without error and continue to enforce policy correctly', async () => {
await expect(reloadOpaPolicy()).resolves.toBeUndefined();
// After reload, fallback mode should still work — create a fresh middleware
const mw = await createOpaMiddleware();
const next = jest.fn() as jest.MockedFunction<NextFunction>;
const req = {
method: 'GET',
baseUrl: '/api/v1',
path: '/agents',
user: {
sub: 'agent-xyz',
client_id: 'agent-xyz',
scope: 'agents:read',
jti: 'jti-reload',
iat: 1000,
exp: 9999999999,
},
} as unknown as Request;
mw(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(/* no error */);
});
it('should still deny access after reload when scope is insufficient', async () => {
await reloadOpaPolicy();
const mw = await createOpaMiddleware();
const next = jest.fn() as jest.MockedFunction<NextFunction>;
const req = {
method: 'POST',
baseUrl: '/api/v1',
path: '/token/introspect',
user: {
sub: 'agent-xyz',
client_id: 'agent-xyz',
scope: 'agents:read',
jti: 'jti-reload-deny',
iat: 1000,
exp: 9999999999,
},
} as unknown as Request;
mw(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
});

View File

@@ -0,0 +1,169 @@
/**
* Unit tests for src/middleware/opa.ts — Wasm mode and fail-closed edge cases.
*
* This file is kept separate from opa.test.ts because it needs different
* `fs.existsSync` and `@open-policy-agent/opa-wasm` mock behaviour.
*
* Jest's module registry is isolated per test file, so the module-level
* singletons (`wasmPolicy`, `scopesMap`) are fresh for each file.
*/
import path from 'path';
import { Request, Response, NextFunction } from 'express';
import { RequestHandler } from 'express';
import { ITokenPayload } from '../../../src/types/index';
import { AuthorizationError } from '../../../src/utils/errors';
// ─── Point POLICY_DIR at the real policies directory ─────────────────────────
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
const POLICIES_DIR = path.join(PROJECT_ROOT, 'policies');
process.env['POLICY_DIR'] = POLICIES_DIR;
// ─── Wasm mock — a LoadedPolicy-like object ───────────────────────────────────
/** Tracks calls so individual tests can assert on evaluation results. */
const mockEvaluate = jest.fn();
const mockSetData = jest.fn();
const MOCK_LOADED_POLICY = {
evaluate: mockEvaluate,
setData: mockSetData,
};
// Mock @open-policy-agent/opa-wasm BEFORE the module is loaded
jest.mock('@open-policy-agent/opa-wasm', () => ({
loadPolicy: jest.fn().mockResolvedValue(MOCK_LOADED_POLICY),
}));
// ─── Mock fs: existsSync returns true for .wasm AND scopes.json ──────────────
jest.mock('fs', () => {
const actual = jest.requireActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: jest.fn((_filePath: unknown) => {
// Both .wasm and other paths exist
return true;
}),
readFileSync: jest.fn((filePath: unknown, encoding?: unknown) => {
if (typeof filePath === 'string' && filePath.endsWith('.wasm')) {
// Return a Buffer-like object for the Wasm bundle
return Buffer.from('fake-wasm-bytes');
}
// For scopes.json, delegate to the real fs
return actual.readFileSync(filePath as string, encoding as BufferEncoding);
}),
};
});
// Import AFTER mocks
import { createOpaMiddleware, reloadOpaPolicy } from '../../../src/middleware/opa';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(scope: string): ITokenPayload {
return {
sub: 'agent-wasm-test',
client_id: 'agent-wasm-test',
scope,
jti: 'jti-wasm-001',
iat: 1000,
exp: 9999999999,
};
}
function makeReq(
method: string,
baseUrl: string,
reqPath: string,
user?: ITokenPayload,
): Partial<Request> {
return { method, baseUrl, path: reqPath, user };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('createOpaMiddleware (Wasm mode)', () => {
let middleware: RequestHandler;
let next: jest.MockedFunction<NextFunction>;
beforeAll(async () => {
middleware = await createOpaMiddleware();
});
beforeEach(() => {
next = jest.fn();
mockEvaluate.mockReset();
});
it('should load in Wasm mode and call setData with scopes.json', () => {
// setData is called once during createOpaMiddleware() → loadWasmPolicy()
expect(mockSetData).toHaveBeenCalledTimes(1);
});
it('should allow request when Wasm policy evaluate returns allow: true', () => {
mockEvaluate.mockReturnValue([{ result: { allow: true } }]);
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(/* no args */);
});
it('should deny request when Wasm policy evaluate returns allow: false', () => {
mockEvaluate.mockReturnValue([{ result: { allow: false } }]);
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should deny request when Wasm evaluate returns empty result set', () => {
mockEvaluate.mockReturnValue([]);
const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:write')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should deny request when Wasm evaluate returns non-array result', () => {
mockEvaluate.mockReturnValue(null);
const req = makeReq('GET', '/api/v1', '/audit', makeUser('audit:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should propagate unexpected errors thrown by Wasm evaluate to next', () => {
const wasmError = new Error('Wasm evaluation failure');
mockEvaluate.mockImplementation(() => { throw wasmError; });
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
middleware(req, {} as Response, next);
expect(next).toHaveBeenCalledWith(wasmError);
});
it('should call next(AuthorizationError) with "not authenticated" when req.user is absent in Wasm mode', () => {
const req = makeReq('GET', '/api/v1', '/agents', undefined) as Request;
middleware(req, {} as Response, next);
const err = (next as jest.Mock).mock.calls[0][0] as AuthorizationError;
expect(err).toBeInstanceOf(AuthorizationError);
expect(err.message).toMatch(/not authenticated/i);
});
});
describe('reloadOpaPolicy (Wasm mode)', () => {
it('should reload in Wasm mode without error', async () => {
await expect(reloadOpaPolicy()).resolves.toBeUndefined();
// setData should have been called again during reload
expect(mockSetData).toHaveBeenCalled();
});
});