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:
177
src/controllers/DelegationController.ts
Normal file
177
src/controllers/DelegationController.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
30
src/db/migrations/024_add_delegation_chains.sql
Normal file
30
src/db/migrations/024_add_delegation_chains.sql
Normal 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
52
src/routes/delegation.ts
Normal 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;
|
||||
}
|
||||
334
src/services/DelegationService.ts
Normal file
334
src/services/DelegationService.ts
Normal 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
53
src/types/delegation.ts
Normal 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
|
||||
}
|
||||
45
src/utils/delegationCrypto.ts
Normal file
45
src/utils/delegationCrypto.ts
Normal 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();
|
||||
}
|
||||
275
tests/integration/delegation.test.ts
Normal file
275
tests/integration/delegation.test.ts
Normal file
@@ -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<typeof AuditService>;
|
||||
|
||||
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>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
284
tests/unit/services/DelegationService.test.ts
Normal file
284
tests/unit/services/DelegationService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
89
tests/unit/utils/delegationCrypto.test.ts
Normal file
89
tests/unit/utils/delegationCrypto.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user