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,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<void> => {
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<void> => {
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<void> => {
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);
}
};
}

View File

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

52
src/routes/delegation.ts Normal file
View File

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

View File

@@ -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<DelegationChain>;
/**
* 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<DelegationVerificationResult>;
/**
* Revoke a delegation chain. Only the delegator may revoke.
*/
revokeDelegation(
chainId: string,
requestingAgentId: string,
ipAddress: string,
userAgent: string,
): Promise<void>;
}
/**
* 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<DelegationChain> {
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<DelegationChainRow> = 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<DelegationVerificationResult> {
const result: QueryResult<DelegationChainRow> = 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<void> {
const result: QueryResult<DelegationChainRow> = 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,
};
}
}

53
src/types/delegation.ts Normal file
View File

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

View File

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