feat(platform-service): allow scoped api keys on webhook routes
This commit is contained in:
parent
da744ab116
commit
8de22f9f22
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user