diff --git a/src/controllers/DelegationController.ts b/src/controllers/DelegationController.ts new file mode 100644 index 0000000..511e964 --- /dev/null +++ b/src/controllers/DelegationController.ts @@ -0,0 +1,177 @@ +/** + * Delegation Controller for SentryAgent.ai AgentIdP. + * HTTP handlers for A2A delegation endpoints. No business logic — delegates to DelegationService. + */ + +import { Request, Response, NextFunction } from 'express'; +import { DelegationService } from '../services/DelegationService.js'; +import { AuthorizationError, ValidationError } from '../utils/errors.js'; +import { + delegationsCreatedTotal, + delegationsVerifiedTotal, + delegationsRevokedTotal, +} from '../metrics/registry.js'; + +/** + * Controller for A2A delegation endpoints. + * Receives DelegationService via constructor injection. + */ +export class DelegationController { + /** + * @param delegationService - The A2A delegation service. + */ + constructor(private readonly delegationService: DelegationService) {} + + /** + * Handles POST /oauth2/token/delegate — creates a delegation chain. + * + * @param req - Express request with CreateDelegationRequest body. + * @param res - Express response. + * @param next - Express next function. + */ + createDelegation = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AuthorizationError(); + } + + const { delegateeAgentId, scopes, ttlSeconds } = req.body as { + delegateeAgentId?: unknown; + scopes?: unknown; + ttlSeconds?: unknown; + }; + + if (typeof delegateeAgentId !== 'string' || !delegateeAgentId) { + throw new ValidationError('delegateeAgentId is required and must be a string.'); + } + if (!Array.isArray(scopes) || scopes.length === 0) { + throw new ValidationError('scopes must be a non-empty array.'); + } + if (typeof ttlSeconds !== 'number' || !Number.isInteger(ttlSeconds)) { + throw new ValidationError('ttlSeconds must be an integer.'); + } + + const tenantId = req.user.organization_id ?? 'org_system'; + const delegatorAgentId = req.user.sub; + const delegatorScopes = req.user.scope ? req.user.scope.split(' ') : []; + const ipAddress = req.ip ?? '0.0.0.0'; + const userAgent = req.headers['user-agent'] ?? 'unknown'; + + const chain = await this.delegationService.createDelegation( + tenantId, + delegatorAgentId, + delegatorScopes, + { delegateeAgentId, scopes: scopes as string[], ttlSeconds }, + ipAddress, + userAgent, + ); + + delegationsCreatedTotal.labels(tenantId).inc(); + + res.status(201).json({ + delegationToken: chain.delegationToken, + chainId: chain.id, + delegatorAgentId: chain.delegatorAgentId, + delegateeAgentId: chain.delegateeAgentId, + scopes: chain.scopes, + expiresAt: chain.expiresAt.toISOString(), + }); + } catch (err) { + next(err); + } + }; + + /** + * Handles POST /oauth2/token/verify-delegation — verifies a delegation token. + * + * @param req - Express request with delegationToken body. + * @param res - Express response. + * @param next - Express next function. + */ + verifyDelegation = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AuthorizationError(); + } + + const { delegationToken } = req.body as { delegationToken?: unknown }; + + if (typeof delegationToken !== 'string' || !delegationToken) { + throw new ValidationError('delegationToken is required and must be a string.'); + } + + const agentId = req.user.sub; + const tenantId = req.user.organization_id ?? 'org_system'; + const ipAddress = req.ip ?? '0.0.0.0'; + const userAgent = req.headers['user-agent'] ?? 'unknown'; + + const result = await this.delegationService.verifyDelegation( + delegationToken, + agentId, + ipAddress, + userAgent, + ); + + const outcomeLabel = result.valid + ? 'valid' + : result.revokedAt !== null + ? 'revoked' + : new Date() > result.expiresAt + ? 'expired' + : 'invalid'; + + delegationsVerifiedTotal.labels(tenantId, outcomeLabel).inc(); + + res.status(200).json({ + valid: result.valid, + chainId: result.chainId, + delegatorAgentId: result.delegatorAgentId, + delegateeAgentId: result.delegateeAgentId, + scopes: result.scopes, + issuedAt: result.issuedAt.toISOString(), + expiresAt: result.expiresAt.toISOString(), + revokedAt: result.revokedAt ? result.revokedAt.toISOString() : null, + }); + } catch (err) { + next(err); + } + }; + + /** + * Handles DELETE /oauth2/token/delegate/:chainId — revokes a delegation chain. + * + * @param req - Express request with chainId path param. + * @param res - Express response. + * @param next - Express next function. + */ + revokeDelegation = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AuthorizationError(); + } + + const { chainId } = req.params; + if (!chainId) { + throw new ValidationError('chainId path parameter is required.'); + } + + const requestingAgentId = req.user.sub; + const tenantId = req.user.organization_id ?? 'org_system'; + const ipAddress = req.ip ?? '0.0.0.0'; + const userAgent = req.headers['user-agent'] ?? 'unknown'; + + await this.delegationService.revokeDelegation( + chainId, + requestingAgentId, + ipAddress, + userAgent, + ); + + delegationsRevokedTotal.labels(tenantId).inc(); + + res.status(204).send(); + } catch (err) { + next(err); + } + }; +} diff --git a/src/db/migrations/024_add_delegation_chains.sql b/src/db/migrations/024_add_delegation_chains.sql new file mode 100644 index 0000000..b6cf2e8 --- /dev/null +++ b/src/db/migrations/024_add_delegation_chains.sql @@ -0,0 +1,30 @@ +-- Migration 024: Add delegation_chains table for A2A authorization (Phase 5 WS2) +-- Creates the delegation_chains table that stores A2A delegation tokens and their +-- verification signatures, enabling agent-to-agent authority delegation. + +CREATE TABLE delegation_chains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id VARCHAR(40) NOT NULL, + delegator_agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, + delegatee_agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, + scopes TEXT[] NOT NULL, + delegation_token TEXT NOT NULL UNIQUE, + signature TEXT NOT NULL, + ttl_seconds INTEGER NOT NULL CHECK (ttl_seconds BETWEEN 60 AND 86400), + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for token lookup (verify-delegation hot path) +CREATE UNIQUE INDEX idx_delegation_chains_token ON delegation_chains(delegation_token); + +-- Index for listing delegations by delegator agent +CREATE INDEX idx_delegation_chains_delegator ON delegation_chains(delegator_agent_id, tenant_id); + +-- Index for listing delegations by delegatee agent +CREATE INDEX idx_delegation_chains_delegatee ON delegation_chains(delegatee_agent_id, tenant_id); + +-- Index for cleanup of expired chains +CREATE INDEX idx_delegation_chains_expires_at ON delegation_chains(expires_at); diff --git a/src/routes/delegation.ts b/src/routes/delegation.ts new file mode 100644 index 0000000..22085dc --- /dev/null +++ b/src/routes/delegation.ts @@ -0,0 +1,52 @@ +/** + * A2A Delegation routes for SentryAgent.ai AgentIdP. + * All three delegation endpoints require Bearer token authentication. + */ + +import { Router, RequestHandler } from 'express'; +import { DelegationController } from '../controllers/DelegationController.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; + +/** + * Creates and returns the Express router for A2A delegation endpoints. + * + * Routes: + * POST /oauth2/token/delegate — create a delegation chain + * POST /oauth2/token/verify-delegation — verify a delegation token + * DELETE /oauth2/token/delegate/:chainId — revoke a delegation chain + * + * All routes are protected by the JWT authentication middleware. + * + * @param controller - The delegation controller instance. + * @param authMiddleware - The JWT authentication middleware. + * @returns Configured Express router. + */ +export function createDelegationRouter( + controller: DelegationController, + authMiddleware: RequestHandler, +): Router { + const router = Router(); + + // POST /oauth2/token/delegate — authenticated; creates a delegation chain + router.post( + '/oauth2/token/delegate', + authMiddleware, + asyncHandler(controller.createDelegation.bind(controller)), + ); + + // POST /oauth2/token/verify-delegation — authenticated; verifies a delegation token + router.post( + '/oauth2/token/verify-delegation', + authMiddleware, + asyncHandler(controller.verifyDelegation.bind(controller)), + ); + + // DELETE /oauth2/token/delegate/:chainId — authenticated; revokes a delegation chain + router.delete( + '/oauth2/token/delegate/:chainId', + authMiddleware, + asyncHandler(controller.revokeDelegation.bind(controller)), + ); + + return router; +} diff --git a/src/services/DelegationService.ts b/src/services/DelegationService.ts new file mode 100644 index 0000000..1a2b341 --- /dev/null +++ b/src/services/DelegationService.ts @@ -0,0 +1,334 @@ +/** + * A2A Delegation Service for SentryAgent.ai AgentIdP. + * Business logic for creating, verifying, and revoking delegation chains. + */ + +import { Pool, QueryResult } from 'pg'; +import { AuditService } from './AuditService.js'; +import { + DelegationChain, + CreateDelegationRequest, + DelegationVerificationResult, + DelegationTokenPayload, +} from '../types/delegation.js'; +import { + signDelegationPayload, + verifyDelegationSignature, + generateDelegationToken, +} from '../utils/delegationCrypto.js'; +import { + AgentNotFoundError, + ValidationError, + AuthorizationError, + CredentialAlreadyRevokedError, +} from '../utils/errors.js'; + +/** Database row shape returned by delegation_chains queries. */ +interface DelegationChainRow { + id: string; + tenant_id: string; + delegator_agent_id: string; + delegatee_agent_id: string; + scopes: string[]; + delegation_token: string; + signature: string; + ttl_seconds: number; + issued_at: Date; + expires_at: Date; + revoked_at: Date | null; + created_at: Date; +} + +/** Interface contract for the delegation service. */ +export interface IDelegationService { + /** + * Create a delegation chain from delegator to delegatee. + * Validates scope subset, signs payload, inserts DB row, writes audit log. + */ + createDelegation( + tenantId: string, + delegatorAgentId: string, + delegatorScopes: string[], + request: CreateDelegationRequest, + ipAddress: string, + userAgent: string, + ): Promise; + + /** + * Verify a delegation token. Returns chain details with valid flag. + * Does not throw on expired/revoked — returns valid: false. + */ + verifyDelegation( + delegationToken: string, + agentId: string, + ipAddress: string, + userAgent: string, + ): Promise; + + /** + * Revoke a delegation chain. Only the delegator may revoke. + */ + revokeDelegation( + chainId: string, + requestingAgentId: string, + ipAddress: string, + userAgent: string, + ): Promise; +} + +/** + * Implementation of IDelegationService. + * Uses PostgreSQL for persistence and HMAC-SHA256 for delegation payload signing. + */ +export class DelegationService implements IDelegationService { + private readonly delegationSecret: string; + + /** + * @param pool - PostgreSQL connection pool. + * @param auditService - Audit log service for recording delegation events. + */ + constructor( + private readonly pool: Pool, + private readonly auditService: AuditService, + ) { + this.delegationSecret = + process.env['DELEGATION_SECRET'] ?? process.env['JWT_SECRET'] ?? 'delegation-secret-change-me'; + } + + /** + * Creates a delegation chain, signing the payload and storing it in the database. + * + * @param tenantId - The organization ID of the delegating agent. + * @param delegatorAgentId - UUID of the agent granting delegation. + * @param delegatorScopes - Scopes held by the delegator (from their JWT). + * @param request - Delegation request body. + * @param ipAddress - Caller IP for audit log. + * @param userAgent - Caller user-agent for audit log. + * @returns The created DelegationChain record. + * @throws ValidationError on invalid scopes, ttl, or self-delegation. + * @throws AgentNotFoundError if delegateeAgentId does not exist in the same tenant. + */ + async createDelegation( + tenantId: string, + delegatorAgentId: string, + delegatorScopes: string[], + request: CreateDelegationRequest, + ipAddress: string, + userAgent: string, + ): Promise { + const { delegateeAgentId, scopes, ttlSeconds } = request; + + // Validate ttl range + if (ttlSeconds < 60 || ttlSeconds > 86400) { + throw new ValidationError('ttlSeconds must be between 60 and 86400.', { code: 'INVALID_TTL' }); + } + + // Reject self-delegation + if (delegateeAgentId === delegatorAgentId) { + throw new ValidationError('An agent cannot delegate to itself.', { code: 'SELF_DELEGATION' }); + } + + // Validate scopes are a strict subset of the delegator's own scopes + const disallowedScopes = scopes.filter((s) => !delegatorScopes.includes(s)); + if (disallowedScopes.length > 0) { + throw new ValidationError( + `Requested scopes exceed delegator permissions: ${disallowedScopes.join(', ')}`, + { code: 'INVALID_SCOPES', disallowedScopes }, + ); + } + + // Verify delegatee exists in the same tenant + const delegateeResult: QueryResult<{ agent_id: string }> = await this.pool.query( + `SELECT agent_id FROM agents WHERE agent_id = $1 AND organization_id = $2 AND status = 'active'`, + [delegateeAgentId, tenantId], + ); + if (delegateeResult.rows.length === 0) { + throw new AgentNotFoundError(delegateeAgentId); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlSeconds * 1000); + const chainId = generateDelegationToken(); // UUID for the chain id + const delegationToken = generateDelegationToken(); // UUID for the token value + + const payload: DelegationTokenPayload = { + chainId, + tenantId, + delegatorAgentId, + delegateeAgentId, + scopes, + issuedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const signature = signDelegationPayload(payload, this.delegationSecret); + + const insertResult: QueryResult = await this.pool.query( + `INSERT INTO delegation_chains + (id, tenant_id, delegator_agent_id, delegatee_agent_id, scopes, + delegation_token, signature, ttl_seconds, issued_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + chainId, + tenantId, + delegatorAgentId, + delegateeAgentId, + scopes, + delegationToken, + signature, + ttlSeconds, + now, + expiresAt, + ], + ); + + await this.auditService.logEvent( + delegatorAgentId, + 'delegation.created', + 'success', + ipAddress, + userAgent, + { chainId, delegateeAgentId, scopes, ttlSeconds }, + tenantId, + ); + + return this.rowToChain(insertResult.rows[0]); + } + + /** + * Verifies a delegation token. Returns valid: false for expired or revoked chains + * rather than throwing an error. + * + * @param delegationToken - The delegation token to verify. + * @param agentId - The agent performing verification (for audit log). + * @param ipAddress - Caller IP for audit log. + * @param userAgent - Caller user-agent for audit log. + * @returns DelegationVerificationResult with valid flag. + * @throws AgentNotFoundError if no chain is found for the given token. + */ + async verifyDelegation( + delegationToken: string, + agentId: string, + ipAddress: string, + userAgent: string, + ): Promise { + const result: QueryResult = await this.pool.query( + `SELECT * FROM delegation_chains WHERE delegation_token = $1`, + [delegationToken], + ); + + if (result.rows.length === 0) { + throw new AgentNotFoundError(delegationToken); + } + + const row = result.rows[0]; + const now = new Date(); + + const payload: DelegationTokenPayload = { + chainId: row.id, + tenantId: row.tenant_id, + delegatorAgentId: row.delegator_agent_id, + delegateeAgentId: row.delegatee_agent_id, + scopes: row.scopes, + issuedAt: row.issued_at.toISOString(), + expiresAt: row.expires_at.toISOString(), + }; + + const signatureValid = verifyDelegationSignature(payload, row.signature, this.delegationSecret); + const notExpired = row.expires_at > now; + const notRevoked = row.revoked_at === null; + const valid = signatureValid && notExpired && notRevoked; + + await this.auditService.logEvent( + agentId, + 'delegation.verified', + 'success', + ipAddress, + userAgent, + { chainId: row.id, valid, signatureValid, notExpired, notRevoked }, + row.tenant_id, + ); + + return { + valid, + chainId: row.id, + delegatorAgentId: row.delegator_agent_id, + delegateeAgentId: row.delegatee_agent_id, + scopes: row.scopes, + issuedAt: row.issued_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }; + } + + /** + * Revokes a delegation chain. Only the original delegator may revoke. + * + * @param chainId - UUID of the delegation chain to revoke. + * @param requestingAgentId - The agent attempting revocation (must be the delegator). + * @param ipAddress - Caller IP for audit log. + * @param userAgent - Caller user-agent for audit log. + * @throws AgentNotFoundError if no chain with this ID exists. + * @throws AuthorizationError if the requesting agent is not the delegator. + * @throws CredentialAlreadyRevokedError if the chain is already revoked. + */ + async revokeDelegation( + chainId: string, + requestingAgentId: string, + ipAddress: string, + userAgent: string, + ): Promise { + const result: QueryResult = await this.pool.query( + `SELECT * FROM delegation_chains WHERE id = $1`, + [chainId], + ); + + if (result.rows.length === 0) { + throw new AgentNotFoundError(chainId); + } + + const row = result.rows[0]; + + if (row.delegator_agent_id !== requestingAgentId) { + throw new AuthorizationError('Only the delegating agent may revoke this delegation chain.'); + } + + if (row.revoked_at !== null) { + throw new CredentialAlreadyRevokedError(chainId, row.revoked_at.toISOString()); + } + + await this.pool.query( + `UPDATE delegation_chains SET revoked_at = NOW() WHERE id = $1`, + [chainId], + ); + + await this.auditService.logEvent( + requestingAgentId, + 'delegation.revoked', + 'success', + ipAddress, + userAgent, + { chainId, delegateeAgentId: row.delegatee_agent_id }, + row.tenant_id, + ); + } + + /** Maps a database row to a DelegationChain domain object. */ + private rowToChain(row: DelegationChainRow): DelegationChain { + return { + id: row.id, + tenantId: row.tenant_id, + delegatorAgentId: row.delegator_agent_id, + delegateeAgentId: row.delegatee_agent_id, + scopes: row.scopes, + delegationToken: row.delegation_token, + signature: row.signature, + ttlSeconds: row.ttl_seconds, + issuedAt: row.issued_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + createdAt: row.created_at, + }; + } +} diff --git a/src/types/delegation.ts b/src/types/delegation.ts new file mode 100644 index 0000000..e2c19d0 --- /dev/null +++ b/src/types/delegation.ts @@ -0,0 +1,53 @@ +/** + * TypeScript interfaces for the A2A (Agent-to-Agent) delegation subsystem. + * All delegation types are defined here — no inline type definitions in services or controllers. + */ + +/** A delegation chain record as stored in the database. */ +export interface DelegationChain { + id: string; + tenantId: string; + delegatorAgentId: string; + delegateeAgentId: string; + scopes: string[]; + delegationToken: string; + signature: string; + ttlSeconds: number; + issuedAt: Date; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; +} + +/** Request body for creating a new delegation. */ +export interface CreateDelegationRequest { + /** UUID of the agent receiving delegated authority. Must be in the same tenant. */ + delegateeAgentId: string; + /** Scopes to delegate. Must be a strict subset of the delegator's own scopes. */ + scopes: string[]; + /** Delegation lifetime in seconds. Min: 60, Max: 86400. */ + ttlSeconds: number; +} + +/** Result of verifying a delegation token. Never throws on expired/revoked — returns valid: false. */ +export interface DelegationVerificationResult { + valid: boolean; + chainId: string; + delegatorAgentId: string; + delegateeAgentId: string; + scopes: string[]; + issuedAt: Date; + expiresAt: Date; + revokedAt: Date | null; +} + +/** Payload signed by HMAC-SHA256 to produce the delegation signature. */ +export interface DelegationTokenPayload { + chainId: string; + tenantId: string; + delegatorAgentId: string; + delegateeAgentId: string; + scopes: string[]; + issuedAt: string; // ISO 8601 + expiresAt: string; // ISO 8601 +} diff --git a/src/utils/delegationCrypto.ts b/src/utils/delegationCrypto.ts new file mode 100644 index 0000000..931b9eb --- /dev/null +++ b/src/utils/delegationCrypto.ts @@ -0,0 +1,45 @@ +/** + * Cryptographic utilities for A2A delegation chain signing and verification. + * Uses HMAC-SHA256 to produce and verify delegation payload signatures. + */ + +import { createHmac, randomUUID } from 'crypto'; +import { DelegationTokenPayload } from '../types/delegation.js'; + +/** + * Signs a delegation payload using HMAC-SHA256. + * + * @param payload - The delegation token payload to sign. + * @param secret - The HMAC signing secret. + * @returns Hex-encoded HMAC-SHA256 digest of the serialised payload. + */ +export function signDelegationPayload(payload: DelegationTokenPayload, secret: string): string { + const data = JSON.stringify(payload); + return createHmac('sha256', secret).update(data).digest('hex'); +} + +/** + * Verifies a delegation payload against an expected HMAC-SHA256 signature. + * + * @param payload - The delegation token payload to verify. + * @param signature - The hex-encoded signature to check against. + * @param secret - The HMAC signing secret. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyDelegationSignature( + payload: DelegationTokenPayload, + signature: string, + secret: string, +): boolean { + const expected = signDelegationPayload(payload, secret); + return expected === signature; +} + +/** + * Generates a unique delegation token (UUID v4). + * + * @returns A random UUID string to use as the delegation token. + */ +export function generateDelegationToken(): string { + return randomUUID(); +} diff --git a/tests/integration/delegation.test.ts b/tests/integration/delegation.test.ts new file mode 100644 index 0000000..d3280fb --- /dev/null +++ b/tests/integration/delegation.test.ts @@ -0,0 +1,275 @@ +/** + * Integration tests for A2A delegation endpoints. + * Tests POST /oauth2/token/delegate, POST /oauth2/token/verify-delegation, + * DELETE /oauth2/token/delegate/:chainId + * + * Requires a running PostgreSQL instance and valid JWT environment variables. + * Uses supertest to test the full Express request pipeline. + */ + +import request from 'supertest'; +import express from 'express'; +import { DelegationController } from '../../src/controllers/DelegationController'; +import { DelegationService } from '../../src/services/DelegationService'; +import { createDelegationRouter } from '../../src/routes/delegation'; +import { Pool } from 'pg'; +import { AuditService } from '../../src/services/AuditService'; + +// Mock heavy dependencies for integration-level tests +jest.mock('../../src/services/AuditService'); +const MockAuditService = AuditService as jest.MockedClass; + +const mockQuery = jest.fn(); +const mockPool = { query: mockQuery } as unknown as Pool; + +// Auth middleware that injects a test user +const testAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => { + req.user = { + sub: 'delegator-agent-uuid', + client_id: 'delegator-agent-uuid', + scope: 'agents:read tokens:read audit:read', + jti: 'test-jti', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + organization_id: 'org_system', + }; + next(); +}; + +const DELEGATEE_ID = 'delegatee-agent-uuid'; +const CHAIN_ID = 'chain-uuid-1234'; +const DELEGATION_TOKEN = 'delegation-token-uuid'; + +const MOCK_CHAIN = { + id: CHAIN_ID, + tenant_id: 'org_system', + delegator_agent_id: 'delegator-agent-uuid', + delegatee_agent_id: DELEGATEE_ID, + scopes: ['agents:read'], + delegation_token: DELEGATION_TOKEN, + signature: 'mock-sig', + ttl_seconds: 3600, + issued_at: new Date(), + expires_at: new Date(Date.now() + 3600_000), + revoked_at: null, + created_at: new Date(), +}; + +function buildApp() { + const app = express(); + app.use(express.json()); + + const auditService = new MockAuditService({} as never) as jest.Mocked; + auditService.logEvent = jest.fn().mockResolvedValue({}); + + process.env['DELEGATION_SECRET'] = 'test-secret'; + const delegationService = new DelegationService(mockPool, auditService); + const controller = new DelegationController(delegationService); + app.use('/api/v1', createDelegationRouter(controller, testAuth)); + + // Error handler + app.use( + ( + err: { httpStatus?: number; code?: string; message?: string }, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + res.status(err.httpStatus ?? 500).json({ code: err.code ?? 'ERROR', message: err.message }); + }, + ); + + return app; +} + +describe('Delegation Endpoints', () => { + let app: express.Application; + + beforeEach(() => { + jest.clearAllMocks(); + app = buildApp(); + }); + + afterEach(() => { + delete process.env['DELEGATION_SECRET']; + }); + + // ──────────────────────────────────────────────────────────────── + // POST /api/v1/oauth2/token/delegate + // ──────────────────────────────────────────────────────────────── + + describe('POST /api/v1/oauth2/token/delegate', () => { + it('returns 201 with delegation token on success', async () => { + // delegatee exists in same tenant + mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] }); + // insert returns chain row + mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] }); + + const res = await request(app) + .post('/api/v1/oauth2/token/delegate') + .send({ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('delegationToken'); + expect(res.body).toHaveProperty('chainId', CHAIN_ID); + expect(res.body).toHaveProperty('scopes'); + }); + + it('returns 400 when scopes exceed delegator permissions', async () => { + const res = await request(app) + .post('/api/v1/oauth2/token/delegate') + .send({ delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 }); + + expect(res.status).toBe(400); + }); + + it('returns 422 on self-delegation', async () => { + const res = await request(app) + .post('/api/v1/oauth2/token/delegate') + .send({ + delegateeAgentId: 'delegator-agent-uuid', // same as req.user.sub + scopes: ['agents:read'], + ttlSeconds: 3600, + }); + + expect(res.status).toBe(400); + }); + + it('returns 404 when delegatee does not exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const res = await request(app) + .post('/api/v1/oauth2/token/delegate') + .send({ + delegateeAgentId: 'nonexistent-agent', + scopes: ['agents:read'], + ttlSeconds: 3600, + }); + + expect(res.status).toBe(404); + }); + + it('returns 400 when delegateeAgentId is missing', async () => { + const res = await request(app) + .post('/api/v1/oauth2/token/delegate') + .send({ scopes: ['agents:read'], ttlSeconds: 3600 }); + + expect(res.status).toBe(400); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // POST /api/v1/oauth2/token/verify-delegation + // ──────────────────────────────────────────────────────────────── + + describe('POST /api/v1/oauth2/token/verify-delegation', () => { + it('returns 200 with valid: true for an active chain', async () => { + const { signDelegationPayload } = await import('../../src/utils/delegationCrypto'); + const payload = { + chainId: MOCK_CHAIN.id, + tenantId: MOCK_CHAIN.tenant_id, + delegatorAgentId: MOCK_CHAIN.delegator_agent_id, + delegateeAgentId: MOCK_CHAIN.delegatee_agent_id, + scopes: MOCK_CHAIN.scopes, + issuedAt: MOCK_CHAIN.issued_at.toISOString(), + expiresAt: MOCK_CHAIN.expires_at.toISOString(), + }; + const realSignature = signDelegationPayload(payload, 'test-secret'); + + mockQuery.mockResolvedValueOnce({ + rows: [{ ...MOCK_CHAIN, signature: realSignature }], + }); + + const res = await request(app) + .post('/api/v1/oauth2/token/verify-delegation') + .send({ delegationToken: DELEGATION_TOKEN }); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(res.body).toHaveProperty('chainId'); + expect(res.body).toHaveProperty('scopes'); + }); + + it('returns 200 with valid: false for an expired chain', async () => { + const expiredChain = { + ...MOCK_CHAIN, + expires_at: new Date(Date.now() - 1000), + }; + mockQuery.mockResolvedValueOnce({ rows: [expiredChain] }); + + const res = await request(app) + .post('/api/v1/oauth2/token/verify-delegation') + .send({ delegationToken: DELEGATION_TOKEN }); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns 200 with valid: false for a revoked chain', async () => { + const revokedChain = { ...MOCK_CHAIN, revoked_at: new Date() }; + mockQuery.mockResolvedValueOnce({ rows: [revokedChain] }); + + const res = await request(app) + .post('/api/v1/oauth2/token/verify-delegation') + .send({ delegationToken: DELEGATION_TOKEN }); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.revokedAt).toBeTruthy(); + }); + + it('returns 404 when no chain found for the token', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const res = await request(app) + .post('/api/v1/oauth2/token/verify-delegation') + .send({ delegationToken: 'nonexistent-token' }); + + expect(res.status).toBe(404); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // DELETE /api/v1/oauth2/token/delegate/:chainId + // ──────────────────────────────────────────────────────────────── + + describe('DELETE /api/v1/oauth2/token/delegate/:chainId', () => { + it('returns 204 when delegator revokes their own chain', async () => { + mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] }); // fetch + mockQuery.mockResolvedValueOnce({ rows: [] }); // update + + const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); + + expect(res.status).toBe(204); + }); + + it('returns 403 when non-delegator attempts revocation', async () => { + const chainWithDifferentDelegator = { + ...MOCK_CHAIN, + delegator_agent_id: 'some-other-agent', + }; + mockQuery.mockResolvedValueOnce({ rows: [chainWithDifferentDelegator] }); + + const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); + + expect(res.status).toBe(403); + }); + + it('returns 409 when chain is already revoked', async () => { + const alreadyRevoked = { ...MOCK_CHAIN, revoked_at: new Date() }; + mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] }); + + const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); + + expect(res.status).toBe(409); + }); + + it('returns 404 when chain does not exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const res = await request(app).delete(`/api/v1/oauth2/token/delegate/nonexistent-chain`); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/tests/unit/services/DelegationService.test.ts b/tests/unit/services/DelegationService.test.ts new file mode 100644 index 0000000..6b6bc80 --- /dev/null +++ b/tests/unit/services/DelegationService.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for src/services/DelegationService.ts + */ + +import { Pool } from 'pg'; +import { DelegationService } from '../../../src/services/DelegationService'; +import { AuditService } from '../../../src/services/AuditService'; +import { + ValidationError, + AgentNotFoundError, + AuthorizationError, + CredentialAlreadyRevokedError, +} from '../../../src/utils/errors'; + +jest.mock('../../../src/services/AuditService'); +const MockAuditService = AuditService as jest.MockedClass; + +// Mock Pool +const mockQuery = jest.fn(); +const mockPool = { query: mockQuery } as unknown as Pool; + +const TENANT_ID = 'org_system'; +const DELEGATOR_ID = 'delegator-uuid-1234'; +const DELEGATEE_ID = 'delegatee-uuid-5678'; +const DELEGATOR_SCOPES = ['agents:read', 'tokens:read', 'audit:read']; +const IP = '127.0.0.1'; +const UA = 'test-agent/1.0'; + +const MOCK_CHAIN_ROW = { + id: 'chain-uuid-1234', + tenant_id: TENANT_ID, + delegator_agent_id: DELEGATOR_ID, + delegatee_agent_id: DELEGATEE_ID, + scopes: ['agents:read'], + delegation_token: 'token-uuid-abcd', + signature: 'mock-signature', + ttl_seconds: 3600, + issued_at: new Date('2026-04-03T00:00:00Z'), + expires_at: new Date(Date.now() + 3600_000), + revoked_at: null, + created_at: new Date('2026-04-03T00:00:00Z'), +}; + +describe('DelegationService', () => { + let service: DelegationService; + let auditService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + // Set delegation secret for tests + process.env['DELEGATION_SECRET'] = 'test-secret'; + auditService = new MockAuditService({} as never) as jest.Mocked; + auditService.logEvent = jest.fn().mockResolvedValue({}); + service = new DelegationService(mockPool, auditService); + }); + + afterEach(() => { + delete process.env['DELEGATION_SECRET']; + }); + + // ──────────────────────────────────────────────────────────────── + // createDelegation + // ──────────────────────────────────────────────────────────────── + + describe('createDelegation', () => { + it('creates a delegation chain successfully', async () => { + // delegatee exists + mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] }); + // insert returns row + mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); + + const result = await service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 }, + IP, + UA, + ); + + expect(result.delegatorAgentId).toBe(DELEGATOR_ID); + expect(result.delegateeAgentId).toBe(DELEGATEE_ID); + expect(auditService.logEvent).toHaveBeenCalledWith( + DELEGATOR_ID, + 'delegation.created', + 'success', + IP, + UA, + expect.objectContaining({ chainId: expect.any(String), delegateeAgentId: DELEGATEE_ID }), + TENANT_ID, + ); + }); + + it('throws ValidationError when scope escalation is attempted', async () => { + await expect( + service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 }, + IP, + UA, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError on self-delegation', async () => { + await expect( + service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: DELEGATOR_ID, scopes: ['agents:read'], ttlSeconds: 3600 }, + IP, + UA, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when ttlSeconds is below minimum', async () => { + await expect( + service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 30 }, + IP, + UA, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when ttlSeconds exceeds maximum', async () => { + await expect( + service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 99999 }, + IP, + UA, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws AgentNotFoundError when delegatee is in a different tenant', async () => { + // delegatee not found in this tenant + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await expect( + service.createDelegation( + TENANT_ID, + DELEGATOR_ID, + DELEGATOR_SCOPES, + { delegateeAgentId: 'other-tenant-agent', scopes: ['agents:read'], ttlSeconds: 3600 }, + IP, + UA, + ), + ).rejects.toThrow(AgentNotFoundError); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // verifyDelegation + // ──────────────────────────────────────────────────────────────── + + describe('verifyDelegation', () => { + it('returns valid: true for an active, non-expired chain', async () => { + // Need a real signature to pass verification + const { signDelegationPayload } = await import('../../../src/utils/delegationCrypto'); + const payload = { + chainId: MOCK_CHAIN_ROW.id, + tenantId: MOCK_CHAIN_ROW.tenant_id, + delegatorAgentId: MOCK_CHAIN_ROW.delegator_agent_id, + delegateeAgentId: MOCK_CHAIN_ROW.delegatee_agent_id, + scopes: MOCK_CHAIN_ROW.scopes, + issuedAt: MOCK_CHAIN_ROW.issued_at.toISOString(), + expiresAt: MOCK_CHAIN_ROW.expires_at.toISOString(), + }; + const realSignature = signDelegationPayload(payload, 'test-secret'); + + mockQuery.mockResolvedValueOnce({ + rows: [{ ...MOCK_CHAIN_ROW, signature: realSignature }], + }); + + const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); + + expect(result.valid).toBe(true); + expect(result.chainId).toBe(MOCK_CHAIN_ROW.id); + expect(auditService.logEvent).toHaveBeenCalledWith( + DELEGATEE_ID, + 'delegation.verified', + 'success', + IP, + UA, + expect.objectContaining({ valid: true }), + TENANT_ID, + ); + }); + + it('returns valid: false (not throws) for an expired chain', async () => { + const expiredRow = { + ...MOCK_CHAIN_ROW, + expires_at: new Date(Date.now() - 1000), // past + signature: 'any-sig', + }; + mockQuery.mockResolvedValueOnce({ rows: [expiredRow] }); + + const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); + + expect(result.valid).toBe(false); + }); + + it('returns valid: false (not throws) for a revoked chain', async () => { + const revokedRow = { + ...MOCK_CHAIN_ROW, + revoked_at: new Date(), + signature: 'any-sig', + }; + mockQuery.mockResolvedValueOnce({ rows: [revokedRow] }); + + const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); + + expect(result.valid).toBe(false); + expect(result.revokedAt).toBeDefined(); + }); + + it('throws AgentNotFoundError when no chain exists for the token', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await expect( + service.verifyDelegation('nonexistent-token', DELEGATEE_ID, IP, UA), + ).rejects.toThrow(AgentNotFoundError); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // revokeDelegation + // ──────────────────────────────────────────────────────────────── + + describe('revokeDelegation', () => { + it('revokes a delegation chain when called by the delegator', async () => { + mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); // fetch chain + mockQuery.mockResolvedValueOnce({ rows: [] }); // update revoked_at + + await service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA); + + expect(auditService.logEvent).toHaveBeenCalledWith( + DELEGATOR_ID, + 'delegation.revoked', + 'success', + IP, + UA, + expect.objectContaining({ chainId: MOCK_CHAIN_ROW.id }), + TENANT_ID, + ); + }); + + it('throws AuthorizationError when a non-delegator attempts revocation', async () => { + mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); + + await expect( + service.revokeDelegation(MOCK_CHAIN_ROW.id, 'other-agent-id', IP, UA), + ).rejects.toThrow(AuthorizationError); + }); + + it('throws CredentialAlreadyRevokedError when chain is already revoked', async () => { + const alreadyRevoked = { ...MOCK_CHAIN_ROW, revoked_at: new Date() }; + mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] }); + + await expect( + service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA), + ).rejects.toThrow(CredentialAlreadyRevokedError); + }); + + it('throws AgentNotFoundError when chain does not exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await expect( + service.revokeDelegation('nonexistent-chain-id', DELEGATOR_ID, IP, UA), + ).rejects.toThrow(AgentNotFoundError); + }); + }); +}); diff --git a/tests/unit/utils/delegationCrypto.test.ts b/tests/unit/utils/delegationCrypto.test.ts new file mode 100644 index 0000000..59365b6 --- /dev/null +++ b/tests/unit/utils/delegationCrypto.test.ts @@ -0,0 +1,89 @@ +/** + * Unit tests for src/utils/delegationCrypto.ts + */ + +import { + signDelegationPayload, + verifyDelegationSignature, + generateDelegationToken, +} from '../../../src/utils/delegationCrypto'; +import { DelegationTokenPayload } from '../../../src/types/delegation'; + +const MOCK_PAYLOAD: DelegationTokenPayload = { + chainId: 'chain-uuid-1234', + tenantId: 'org_system', + delegatorAgentId: 'delegator-uuid', + delegateeAgentId: 'delegatee-uuid', + scopes: ['agents:read', 'tokens:read'], + issuedAt: '2026-04-03T00:00:00.000Z', + expiresAt: '2026-04-03T01:00:00.000Z', +}; + +const SECRET = 'test-hmac-secret'; + +describe('delegationCrypto', () => { + describe('signDelegationPayload', () => { + it('returns a non-empty hex string', () => { + const sig = signDelegationPayload(MOCK_PAYLOAD, SECRET); + expect(typeof sig).toBe('string'); + expect(sig.length).toBeGreaterThan(0); + // SHA-256 hex = 64 chars + expect(sig).toMatch(/^[0-9a-f]{64}$/); + }); + + it('produces the same signature for the same payload and secret', () => { + const sig1 = signDelegationPayload(MOCK_PAYLOAD, SECRET); + const sig2 = signDelegationPayload(MOCK_PAYLOAD, SECRET); + expect(sig1).toBe(sig2); + }); + + it('produces different signatures for different secrets', () => { + const sig1 = signDelegationPayload(MOCK_PAYLOAD, 'secret-a'); + const sig2 = signDelegationPayload(MOCK_PAYLOAD, 'secret-b'); + expect(sig1).not.toBe(sig2); + }); + + it('produces different signatures for different payloads', () => { + const payload2 = { ...MOCK_PAYLOAD, chainId: 'different-chain-uuid' }; + const sig1 = signDelegationPayload(MOCK_PAYLOAD, SECRET); + const sig2 = signDelegationPayload(payload2, SECRET); + expect(sig1).not.toBe(sig2); + }); + }); + + describe('verifyDelegationSignature', () => { + it('returns true for a valid sign/verify round-trip', () => { + const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET); + expect(verifyDelegationSignature(MOCK_PAYLOAD, signature, SECRET)).toBe(true); + }); + + it('returns false when the payload has been tampered with', () => { + const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET); + const tampered = { ...MOCK_PAYLOAD, scopes: ['admin:orgs'] }; + expect(verifyDelegationSignature(tampered, signature, SECRET)).toBe(false); + }); + + it('returns false when the secret does not match', () => { + const signature = signDelegationPayload(MOCK_PAYLOAD, SECRET); + expect(verifyDelegationSignature(MOCK_PAYLOAD, signature, 'wrong-secret')).toBe(false); + }); + + it('returns false for an empty signature string', () => { + expect(verifyDelegationSignature(MOCK_PAYLOAD, '', SECRET)).toBe(false); + }); + }); + + describe('generateDelegationToken', () => { + it('returns a UUID v4 string', () => { + const token = generateDelegationToken(); + expect(token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it('generates unique tokens on each call', () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateDelegationToken())); + expect(tokens.size).toBe(50); + }); + }); +});