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