Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ app.use('/api/integrations/keys', createApiKeyRouter())
// Policy engine – fine-grained org permissions
app.use('/api/orgs/:orgId/policies', createPolicyRouter())

// Webhook management (secret rotation, etc.)
const webhookStore = new MemoryWebhookStore()
app.use('/api/webhooks', createWebhookRouter(webhookStore, auditLogService))

const analyticsThresholdSeconds = Number(process.env.ANALYTICS_STALENESS_SECONDS ?? '300')
const analyticsService = process.env.DATABASE_URL
? new AnalyticsService(pool, analyticsThresholdSeconds)
Expand Down
79 changes: 79 additions & 0 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express'
import { AuthenticatedRequest, requireUserAuth, requireAdminRole } from '../middleware/auth.js'
import { WebhookRotationService, WebhookNotFoundError } from '../services/webhooks/rotationService.js'
import type { WebhookStore } from '../services/webhooks/types.js'
import type { AuditLogService } from '../services/audit/index.js'

/**
* Create the webhook management router.
*
* Injecting store and audit allows tests to supply in-memory doubles
* without touching module-level singletons.
*/
export function createWebhookRouter(store: WebhookStore, audit: AuditLogService): Router {
const router = Router()
const rotationService = new WebhookRotationService(store, audit)

/**
* POST /api/webhooks/:webhookId/rotate-secret
*
* Rotate the HMAC signing secret for a webhook.
*
* Safe-rollout: the previous secret remains valid for 24 h so that
* consumers can migrate without dropping events.
*
* The new secret is returned exactly once in this response and is never
* stored in plain text. Store it securely immediately.
*
* @requires Admin role
*
* @param webhookId - ID of the webhook whose secret should be rotated
*
* @returns {object} Rotation result
* @returns {string} .webhookId
* @returns {string} .newSecret — plain-text secret, shown once only
* @returns {string} .rotatedAt — ISO timestamp of rotation
* @returns {string} .previousSecretExpiresAt — ISO timestamp when old secret expires
*/
router.post(
'/:webhookId/rotate-secret',
requireUserAuth,
requireAdminRole,
async (req: Request, res: Response): Promise<void> => {
const { webhookId } = req.params
const authReq = req as AuthenticatedRequest
const actor = authReq.user!
const ipAddress = req.ip ?? req.socket.remoteAddress

try {
const result = await rotationService.rotateSecret(
webhookId,
actor.id,
actor.email,
ipAddress,
)

res.status(200).json({
success: true,
data: result,
})
} catch (err) {
if (err instanceof WebhookNotFoundError) {
res.status(404).json({
error: 'NotFound',
message: err.message,
})
return
}

const message = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({
error: 'InternalError',
message,
})
}
},
)

return router
}
19 changes: 19 additions & 0 deletions src/services/webhooks/memoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,23 @@ export class MemoryWebhookStore implements WebhookStore {
async set(config: WebhookConfig): Promise<void> {
this.webhooks.set(config.id, config)
}

async rotateSecret(
id: string,
newSecret: string,
previousSecret: string,
previousSecretExpiresAt: string,
): Promise<WebhookConfig> {
const existing = this.webhooks.get(id)
if (!existing) throw new Error(`Webhook not found: ${id}`)
const updated: WebhookConfig = {
...existing,
secret: newSecret,
previousSecret,
secretRotatedAt: new Date().toISOString(),
previousSecretExpiresAt,
}
this.webhooks.set(id, updated)
return updated
}
}
73 changes: 73 additions & 0 deletions src/services/webhooks/rotationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { randomBytes } from 'crypto'
import type { WebhookStore, WebhookSecretRotationResult } from './types.js'
import type { AuditLogService } from '../audit/index.js'
import { AuditAction } from '../audit/index.js'

/** Grace period during which the previous secret remains valid for client verification. */
const PREVIOUS_SECRET_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours

export class WebhookNotFoundError extends Error {
constructor(webhookId: string) {
super(`Webhook not found: ${webhookId}`)
this.name = 'WebhookNotFoundError'
}
}

/**
* Handles webhook signing-secret rotation with safe-rollout semantics:
* - Generates a cryptographically random new secret.
* - Keeps the old secret alive as `previousSecret` for 24 h so consumers
* can migrate without downtime.
* - Emits an audit log entry for every rotation attempt (success or failure).
*/
export class WebhookRotationService {
constructor(
private readonly store: WebhookStore,
private readonly audit: AuditLogService,
) {}

async rotateSecret(
webhookId: string,
actorId: string,
actorEmail: string,
ipAddress?: string,
): Promise<WebhookSecretRotationResult> {
const webhook = await this.store.get(webhookId)

if (!webhook) {
this.audit.logAction(
actorId,
actorEmail,
AuditAction.ROTATE_WEBHOOK_SECRET,
webhookId,
'',
{ webhookId },
'failure',
'Webhook not found',
ipAddress,
)
throw new WebhookNotFoundError(webhookId)
}

const newSecret = randomBytes(32).toString('hex')
const now = new Date()
const previousSecretExpiresAt = new Date(now.getTime() + PREVIOUS_SECRET_TTL_MS).toISOString()
const rotatedAt = now.toISOString()

await this.store.rotateSecret(webhookId, newSecret, webhook.secret, previousSecretExpiresAt)

this.audit.logAction(
actorId,
actorEmail,
AuditAction.ROTATE_WEBHOOK_SECRET,
webhookId,
'',
{ webhookId, url: webhook.url, previousSecretExpiresAt },
'success',
undefined,
ipAddress,
)

return { webhookId, newSecret, rotatedAt, previousSecretExpiresAt }
}
}
30 changes: 29 additions & 1 deletion src/services/webhooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,31 @@ export interface WebhookConfig {
url: string
/** Events this webhook is subscribed to. */
events: WebhookEventType[]
/** Secret key for HMAC signature verification. */
/** Current HMAC signing secret. */
secret: string
/** Previously active secret (during grace period). */
previousSecret?: string
/** Timestamp when the secret was last rotated. */
secretUpdatedAt: Date
/** Whether this webhook is active. */
active: boolean
/** Previous secret kept alive during safe-rollout grace period. */
previousSecret?: string
/** ISO timestamp when the secret was last rotated. */
secretRotatedAt?: string
/** ISO timestamp after which previousSecret is no longer valid. */
previousSecretExpiresAt?: string
}

/**
* Result returned to the caller after a successful secret rotation.
* newSecret is shown exactly once — it is never persisted in plain text.
*/
export interface WebhookSecretRotationResult {
webhookId: string
newSecret: string
rotatedAt: string
previousSecretExpiresAt: string
}

/**
Expand Down Expand Up @@ -96,4 +113,15 @@ export interface WebhookStore {
get(id: string): Promise<WebhookConfig | null>
/** Save or update webhook config. */
set(config: WebhookConfig): Promise<void>
/**
* Atomically swap in a new signing secret while preserving the old one
* for the given grace period. Implementations must treat this as a single
* operation so concurrent rotations cannot race.
*/
rotateSecret(
id: string,
newSecret: string,
previousSecret: string,
previousSecretExpiresAt: string,
): Promise<WebhookConfig>
}
Loading
Loading