feat(phase-5): WS2 — A2A Authorization

Implements agent-to-agent delegation chains:
- Migration 024: delegation_chains table with HMAC signature, TTL, revocation
- DelegationCrypto: HMAC-SHA256 sign/verify, UUID token generation
- DelegationService: create (scope subset validation, self-delegation guard,
  same-tenant delegatee check), verify (returns valid: false on expired/revoked,
  never throws), revoke (delegator-only, conflict guard)
- DelegationController + router at /oauth2/token/delegate (POST/DELETE) and
  /oauth2/token/verify-delegation (POST)
- Feature-flagged behind A2A_ENABLED env var (default on)
- Prometheus metrics: delegations_created/verified/revoked_total
- 33 tests (unit + integration): all pass, DelegationService 87.5%+ branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-04-03 02:49:36 +00:00
parent 0506bc1b8e
commit 16497706d3
9 changed files with 1339 additions and 0 deletions

View File

@@ -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<typeof AuditService>;
// 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<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
// Set delegation secret for tests
process.env['DELEGATION_SECRET'] = 'test-secret';
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
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);
});
});
});

View File

@@ -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);
});
});
});