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:
@@ -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 () => {
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
169
tests/unit/middleware/opa.wasm.test.ts
Normal file
169
tests/unit/middleware/opa.wasm.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const EXPECTED_CREDENTIAL: ICredential = {
|
||||
const EXPECTED_CREDENTIAL_ROW: ICredentialRow = {
|
||||
...EXPECTED_CREDENTIAL,
|
||||
secretHash: CREDENTIAL_ROW.secret_hash,
|
||||
vaultPath: null,
|
||||
};
|
||||
|
||||
// ─── suite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
InsufficientScopeError,
|
||||
} from '../../../src/utils/errors';
|
||||
import { IAgent, ICredential, ICredentialRow, ITokenPayload } from '../../../src/types/index';
|
||||
import { hashSecret, generateClientSecret } from '../../../src/utils/crypto';
|
||||
@@ -91,7 +90,7 @@ describe('OAuth2Service', () => {
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
credentialRow = { ...mockCredential, secretHash };
|
||||
credentialRow = { ...mockCredential, secretHash, vaultPath: null };
|
||||
|
||||
credentialRepo.findByAgentId.mockResolvedValue({ credentials: [mockCredential], total: 1 });
|
||||
credentialRepo.findById.mockResolvedValue(credentialRow);
|
||||
@@ -188,11 +187,14 @@ describe('OAuth2Service', () => {
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw InsufficientScopeError if caller lacks tokens:read', async () => {
|
||||
it('should introspect successfully regardless of caller scope (tokens:read enforced by OPA middleware)', async () => {
|
||||
// Scope enforcement for tokens:read has been moved to OpaMiddleware.
|
||||
// The service introspects any token presented to it once the request has
|
||||
// passed the middleware layer.
|
||||
tokenRepo.isRevoked.mockResolvedValue(false);
|
||||
const noScopePayload = { ...callerPayload, scope: 'agents:read' };
|
||||
await expect(
|
||||
service.introspectToken(validToken, noScopePayload, IP, UA),
|
||||
).rejects.toThrow(InsufficientScopeError);
|
||||
const result = await service.introspectToken(validToken, noScopePayload, IP, UA);
|
||||
expect(result.active).toBe(true);
|
||||
});
|
||||
|
||||
it('should return active: false for an expired token', async () => {
|
||||
|
||||
Reference in New Issue
Block a user