feat(platform-service): allow scoped api keys on webhook routes

This commit is contained in:
root 2026-03-14 15:38:42 +00:00
parent da744ab116
commit 8de22f9f22
3 changed files with 143 additions and 25 deletions

View File

@ -84,6 +84,8 @@ function loadApiKeyRateLimitConfig(): ApiKeyRateLimitConfig {
'maintenance:write': { maxRequests: 10, windowSeconds: 60 },
'ip-rules:read': { maxRequests: 30, windowSeconds: 60 },
'ip-rules:write': { maxRequests: 10, windowSeconds: 60 },
'webhooks:read': { maxRequests: 60, windowSeconds: 60 },
'webhooks:write': { maxRequests: 15, windowSeconds: 60 },
};
const raw = process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;

View File

@ -1,32 +1,56 @@
import { randomBytes } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { extractAuth } from '../../lib/auth.js';
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
import { CreateWebhookSubscriptionSchema, UpdateWebhookSubscriptionSchema } from './types.js';
import * as repo from './repository.js';
import { dispatchEvent } from './dispatcher.js';
const DEFAULT_PRODUCT_ID = 'lysnrai';
export async function webhookRoutes(app: FastifyInstance) {
function requireWebhooksRead(req: import('fastify').FastifyRequest): string {
const access = requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['webhooks:read'],
rateLimitKey: 'webhooks:read',
});
return access.productId;
}
function requireWebhooksWrite(req: import('fastify').FastifyRequest): {
actorId: string;
productId: string;
} {
const access = requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['webhooks:write'],
rateLimitKey: 'webhooks:write',
});
return {
actorId: access.actorId,
productId: access.productId,
};
}
// List webhook subscriptions
app.get('/webhooks/subscriptions', async req => {
await extractAuth(req);
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const productId = requireWebhooksRead(req);
return repo.listSubscriptions(productId);
});
// Create a webhook subscription
app.post('/webhooks/subscriptions', async req => {
const auth = await extractAuth(req);
const access = requireWebhooksWrite(req);
const parsed = CreateWebhookSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const secret = randomBytes(32).toString('hex');
const sub = await repo.createSubscription(parsed.data, secret, auth.sub);
const sub = await repo.createSubscription(
{ ...parsed.data, productId: access.productId },
secret,
access.actorId
);
// Return secret only on creation (shown once)
return { ...sub, secret };
@ -34,10 +58,8 @@ export async function webhookRoutes(app: FastifyInstance) {
// Get a specific subscription
app.get('/webhooks/subscriptions/:id', async req => {
await extractAuth(req);
const productId = requireWebhooksRead(req);
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const sub = await repo.getSubscription(id, productId);
if (!sub) throw new NotFoundError('Webhook subscription not found');
@ -48,27 +70,23 @@ export async function webhookRoutes(app: FastifyInstance) {
// Update a subscription
app.patch('/webhooks/subscriptions/:id', async req => {
await extractAuth(req);
const access = requireWebhooksWrite(req);
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const parsed = UpdateWebhookSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const updated = await repo.updateSubscription(id, productId, parsed.data);
const updated = await repo.updateSubscription(id, access.productId, parsed.data);
if (!updated) throw new NotFoundError('Webhook subscription not found');
return { ...updated, secret: `${updated.secret.slice(0, 8)}...` };
});
// Delete a subscription
app.delete('/webhooks/subscriptions/:id', async req => {
await extractAuth(req);
const productId = requireWebhooksWrite(req).productId;
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const deleted = await repo.deleteSubscription(id, productId);
if (!deleted) throw new NotFoundError('Webhook subscription not found');
@ -77,7 +95,7 @@ export async function webhookRoutes(app: FastifyInstance) {
// List deliveries for a subscription
app.get('/webhooks/subscriptions/:id/deliveries', async req => {
await extractAuth(req);
requireWebhooksRead(req);
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
return repo.listDeliveries(id, {
@ -87,10 +105,8 @@ export async function webhookRoutes(app: FastifyInstance) {
// Test delivery — send a test event to a specific subscription
app.post('/webhooks/subscriptions/:id/test', async req => {
await extractAuth(req);
const productId = requireWebhooksWrite(req).productId;
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const sub = await repo.getSubscription(id, productId);
if (!sub) throw new NotFoundError('Webhook subscription not found');
@ -107,10 +123,8 @@ export async function webhookRoutes(app: FastifyInstance) {
// Rotate subscription secret
app.post('/webhooks/subscriptions/:id/rotate-secret', async req => {
await extractAuth(req);
const productId = requireWebhooksWrite(req).productId;
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const productId = query.productId || DEFAULT_PRODUCT_ID;
const sub = await repo.getSubscription(id, productId);
if (!sub) throw new NotFoundError('Webhook subscription not found');

View File

@ -0,0 +1,102 @@
import Fastify from 'fastify';
import bcrypt from 'bcryptjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { setProvider } from '../../lib/datastore.js';
import { registerOptionalApiKeyContext } from '../../lib/api-key-auth.js';
const rawApiKey = `wai_${'c'.repeat(64)}`;
const repoMock = {
listSubscriptions: vi.fn(),
createSubscription: vi.fn(),
getSubscription: vi.fn(),
updateSubscription: vi.fn(),
deleteSubscription: vi.fn(),
listDeliveries: vi.fn(),
};
const dispatcherMock = {
dispatchEvent: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
vi.mock('./dispatcher.js', () => dispatcherMock);
async function seedApiKey(scopes: string[]) {
const provider = new MemoryDatastoreProvider();
setProvider(provider);
const collection = provider.getCollection('api_tokens', '/id');
await collection.create({
id: 'tok_webhooks_1',
productId: 'lysnrai',
userId: 'svc_webhooks',
userName: 'Webhook Service',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',
scopes,
expiresAt: '2099-01-01T00:00:00.000Z',
lastUsed: null,
});
}
describe('webhookRoutes api key integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('allows subscription listing via scoped api key', async () => {
await seedApiKey(['webhooks:read']);
repoMock.listSubscriptions.mockResolvedValue([{ id: 'sub_1', productId: 'lysnrai' }]);
const { webhookRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(webhookRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/subscriptions',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(200);
expect(repoMock.listSubscriptions).toHaveBeenCalledWith('lysnrai');
});
it('allows subscription creation via scoped api key', async () => {
await seedApiKey(['webhooks:write']);
repoMock.createSubscription.mockResolvedValue({
id: 'sub_1',
productId: 'lysnrai',
url: 'https://example.com/hook',
secret: 'abc',
});
const { webhookRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(webhookRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/subscriptions',
headers: { 'x-api-key': rawApiKey },
payload: {
url: 'https://example.com/hook',
events: ['user.created'],
productId: 'other-product-ignored',
},
});
expect(res.statusCode).toBe(200);
expect(repoMock.createSubscription).toHaveBeenCalledWith(
expect.objectContaining({
productId: 'lysnrai',
}),
expect.any(String),
'svc_webhooks'
);
});
});