feat(phase-3): workstream 5 — Webhooks & Event Streaming

- DB migrations 016/017: webhook_subscriptions and webhook_deliveries tables
- WebhookService: CRUD for subscriptions, Vault-backed secret storage, delivery history
- WebhookDeliveryWorker: Bull queue, HMAC-SHA256 signatures, exponential backoff,
  SSRF protection (RFC 1918 + loopback + link-local rejection), dead-letter handling
- EventPublisher: publishes 10 event types (agent/credential/token lifecycle);
  optional Kafka adapter activated via KAFKA_BROKERS env var
- AgentService, CredentialService, OAuth2Service: wired to EventPublisher
- WebhookController + routes: 6 endpoints with webhooks:read / webhooks:write scope guards
- KafkaAdapter: optional Kafka producer (kafkajs), no-op when KAFKA_BROKERS unset
- OAuthScope extended: webhooks:read, webhooks:write
- AuditAction extended: webhook.created, webhook.updated, webhook.deleted
- Metrics: agentidp_webhook_dead_letters_total counter added to registry
- 523 unit tests passing; TypeScript strict throughout, zero `any`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-31 00:07:41 +00:00
parent 03b5de300c
commit 272b69f18d
20 changed files with 1994 additions and 25 deletions

View File

@@ -9,6 +9,7 @@ import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import { IDTokenService } from './IDTokenService.js';
import { EventPublisher } from './EventPublisher.js';
import {
ITokenPayload,
ITokenResponse,
@@ -49,6 +50,8 @@ export class OAuth2Service {
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
* @param idTokenService - Optional IDTokenService; when provided and `openid` scope
* is requested, an OIDC ID token is appended to the token response.
* @param eventPublisher - Optional EventPublisher. When provided, token.issued and
* token.revoked events are published as webhooks and Kafka messages (fire-and-forget).
*/
constructor(
private readonly tokenRepository: TokenRepository,
@@ -59,6 +62,7 @@ export class OAuth2Service {
private readonly publicKey: string,
private readonly vaultClient: VaultClient | null = null,
private readonly idTokenService: IDTokenService | null = null,
private readonly eventPublisher: EventPublisher | null = null,
) {}
/**
@@ -211,6 +215,13 @@ export class OAuth2Service {
// Instrument: count successful token issuances
tokensIssuedTotal.inc({ scope });
// Publish event (fire-and-forget)
void this.eventPublisher?.publishEvent(
agent.organizationId ?? 'org_system',
'token.issued',
{ agentId: clientId, scope, jti },
);
const tokenResponse: ITokenResponse = {
access_token: accessToken,
token_type: 'Bearer',
@@ -323,6 +334,13 @@ export class OAuth2Service {
userAgent,
{ jti: decoded.jti },
);
// Publish event (fire-and-forget)
void this.eventPublisher?.publishEvent(
callerPayload.organization_id ?? 'org_system',
'token.revoked',
{ jti: decoded.jti },
);
}
// If token is malformed/undecoded, per RFC 7009 we still return success
}