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:
464
tests/unit/middleware/opa.test.ts
Normal file
464
tests/unit/middleware/opa.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user