feat(platform-service): allow scoped api keys on ops routes
This commit is contained in:
parent
0ad6703961
commit
da744ab116
@ -1,25 +1,30 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
|
||||
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { CreateExportSchema } from './types.js';
|
||||
import type { ExportJobDoc } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
export async function exportRoutes(app: FastifyInstance) {
|
||||
function requireAdmin(req: import('fastify').FastifyRequest): string {
|
||||
const role = req.jwtPayload?.role;
|
||||
if (!role || !['super_admin', 'admin'].includes(role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const sub = req.jwtPayload?.sub;
|
||||
if (!sub) throw new UnauthorizedError('Missing sub in token');
|
||||
return sub;
|
||||
function requireExportRead(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['exports:read'],
|
||||
rateLimitKey: 'exports:read',
|
||||
});
|
||||
}
|
||||
|
||||
function requireExportWrite(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['exports:write'],
|
||||
rateLimitKey: 'exports:write',
|
||||
});
|
||||
}
|
||||
|
||||
// Start a new export job
|
||||
app.post('/exports', async (req, reply) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const access = requireExportWrite(req);
|
||||
const parsed = CreateExportSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
@ -30,12 +35,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
const job: ExportJobDoc = {
|
||||
id: `exp_${crypto.randomUUID()}`,
|
||||
productId: DEFAULT_PRODUCT_ID,
|
||||
productId: access.productId,
|
||||
type: parsed.data.type,
|
||||
format: parsed.data.format,
|
||||
filters: parsed.data.filters,
|
||||
status: 'pending',
|
||||
requestedBy: adminId,
|
||||
requestedBy: access.actorId,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
};
|
||||
@ -54,18 +59,18 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
// List export jobs
|
||||
app.get('/exports', async req => {
|
||||
requireAdmin(req);
|
||||
const access = requireExportRead(req);
|
||||
const query = req.query as Record<string, string>;
|
||||
const limit = query.limit ? parseInt(query.limit, 10) : 20;
|
||||
const jobs = await repo.listExportJobs(DEFAULT_PRODUCT_ID, Math.min(limit, 100));
|
||||
const jobs = await repo.listExportJobs(access.productId, Math.min(limit, 100));
|
||||
return { exports: jobs, count: jobs.length };
|
||||
});
|
||||
|
||||
// Get a specific export job
|
||||
app.get('/exports/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const access = requireExportRead(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const job = await repo.getExportJob(id, DEFAULT_PRODUCT_ID);
|
||||
const job = await repo.getExportJob(id, access.productId);
|
||||
if (!job) throw new BadRequestError('Export job not found');
|
||||
return job;
|
||||
});
|
||||
|
||||
@ -1,31 +1,36 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
|
||||
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { CreateIPRuleSchema } from './types.js';
|
||||
import type { IPRuleDoc } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
export async function ipRuleRoutes(app: FastifyInstance) {
|
||||
function requireAdmin(req: import('fastify').FastifyRequest): string {
|
||||
const role = req.jwtPayload?.role;
|
||||
if (!role || !['super_admin', 'admin'].includes(role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const sub = req.jwtPayload?.sub;
|
||||
if (!sub) throw new UnauthorizedError('Missing sub in token');
|
||||
return sub;
|
||||
function requireIpRulesRead(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['ip-rules:read'],
|
||||
rateLimitKey: 'ip-rules:read',
|
||||
});
|
||||
}
|
||||
|
||||
function requireIpRulesWrite(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['ip-rules:write'],
|
||||
rateLimitKey: 'ip-rules:write',
|
||||
});
|
||||
}
|
||||
|
||||
// List all IP rules
|
||||
app.get('/ratelimit/ip-rules', async req => {
|
||||
requireAdmin(req);
|
||||
return repo.listRules(DEFAULT_PRODUCT_ID);
|
||||
const access = requireIpRulesRead(req);
|
||||
return repo.listRules(access.productId);
|
||||
});
|
||||
|
||||
// Create an IP rule
|
||||
app.post('/ratelimit/ip-rules', async (req, reply) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const access = requireIpRulesWrite(req);
|
||||
const parsed = CreateIPRuleSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
@ -33,11 +38,11 @@ export async function ipRuleRoutes(app: FastifyInstance) {
|
||||
|
||||
const doc: IPRuleDoc = {
|
||||
id: `ipr_${crypto.randomUUID()}`,
|
||||
productId: DEFAULT_PRODUCT_ID,
|
||||
productId: access.productId,
|
||||
ip: parsed.data.ip,
|
||||
action: parsed.data.action,
|
||||
reason: parsed.data.reason,
|
||||
createdBy: adminId,
|
||||
createdBy: access.actorId,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: parsed.data.expiresAt,
|
||||
};
|
||||
@ -53,18 +58,18 @@ export async function ipRuleRoutes(app: FastifyInstance) {
|
||||
|
||||
// Delete an IP rule
|
||||
app.delete('/ratelimit/ip-rules/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const access = requireIpRulesWrite(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const deleted = await repo.deleteRule(id, DEFAULT_PRODUCT_ID);
|
||||
const deleted = await repo.deleteRule(id, access.productId);
|
||||
if (!deleted) throw new BadRequestError('IP rule not found');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Check if an IP is allowed/denied (utility endpoint)
|
||||
app.get('/ratelimit/check-ip/:ip', async req => {
|
||||
requireAdmin(req);
|
||||
const access = requireIpRulesRead(req);
|
||||
const { ip } = req.params as { ip: string };
|
||||
const result = await repo.checkIP(ip, DEFAULT_PRODUCT_ID);
|
||||
const result = await repo.checkIP(ip, access.productId);
|
||||
return { ip, action: result, hasRule: result !== null };
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
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_${'b'.repeat(64)}`;
|
||||
|
||||
const repoMock = {
|
||||
listJobDefinitions: vi.fn(),
|
||||
getJobDefinition: vi.fn(),
|
||||
updateJobDefinition: vi.fn(),
|
||||
listJobRuns: vi.fn(),
|
||||
};
|
||||
|
||||
const registryMock = {
|
||||
getJobHandler: vi.fn(),
|
||||
};
|
||||
|
||||
const runnerMock = {
|
||||
executeJob: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('./repository.js', () => repoMock);
|
||||
vi.mock('./registry.js', () => registryMock);
|
||||
vi.mock('./runner.js', () => runnerMock);
|
||||
|
||||
async function seedApiKey(scopes: string[]) {
|
||||
const provider = new MemoryDatastoreProvider();
|
||||
setProvider(provider);
|
||||
const collection = provider.getCollection('api_tokens', '/id');
|
||||
await collection.create({
|
||||
id: 'tok_jobs_1',
|
||||
productId: 'lysnrai',
|
||||
userId: 'svc_jobs',
|
||||
userName: 'Jobs 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('jobRoutes api key integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
|
||||
});
|
||||
|
||||
it('allows job reads via scoped api key', async () => {
|
||||
await seedApiKey(['jobs:read']);
|
||||
repoMock.listJobDefinitions.mockResolvedValue([{ id: 'job_sync', productId: 'lysnrai' }]);
|
||||
|
||||
const { jobRoutes } = await import('./routes.js');
|
||||
const app = Fastify();
|
||||
await registerOptionalApiKeyContext(app);
|
||||
await app.register(jobRoutes, { prefix: '/api' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-api-key': rawApiKey },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(repoMock.listJobDefinitions).toHaveBeenCalledWith('lysnrai');
|
||||
});
|
||||
|
||||
it('allows job trigger via scoped api key', async () => {
|
||||
await seedApiKey(['jobs:write']);
|
||||
registryMock.getJobHandler.mockReturnValue(() => Promise.resolve({ success: true }));
|
||||
repoMock.getJobDefinition.mockResolvedValue({
|
||||
id: 'job_reindex',
|
||||
productId: 'lysnrai',
|
||||
name: 'reindex',
|
||||
});
|
||||
runnerMock.executeJob.mockResolvedValue({ id: 'run_1', status: 'success' });
|
||||
|
||||
const { jobRoutes } = await import('./routes.js');
|
||||
const app = Fastify();
|
||||
await registerOptionalApiKeyContext(app);
|
||||
await app.register(jobRoutes, { prefix: '/api' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs/trigger',
|
||||
headers: { 'x-api-key': rawApiKey },
|
||||
payload: { jobName: 'reindex' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(repoMock.getJobDefinition).toHaveBeenCalledWith('job_reindex', 'lysnrai');
|
||||
expect(runnerMock.executeJob).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,41 +1,57 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { TriggerJobSchema, UpdateJobSchema } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
import { getJobHandler } from './registry.js';
|
||||
import { executeJob } from './runner.js';
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
export async function jobRoutes(app: FastifyInstance) {
|
||||
function requireJobsRead(req: import('fastify').FastifyRequest): string {
|
||||
const access = requireJwtOrApiKey(req, {
|
||||
allowJwt: true,
|
||||
apiKeyScopes: ['jobs:read'],
|
||||
rateLimitKey: 'jobs:read',
|
||||
});
|
||||
return access.productId;
|
||||
}
|
||||
|
||||
function requireJobsWrite(req: import('fastify').FastifyRequest): string {
|
||||
const access = requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['jobs:write'],
|
||||
rateLimitKey: 'jobs:write',
|
||||
});
|
||||
return access.productId;
|
||||
}
|
||||
|
||||
// List all job definitions
|
||||
app.get('/jobs', async req => {
|
||||
await extractAuth(req);
|
||||
return repo.listJobDefinitions(DEFAULT_PRODUCT_ID);
|
||||
const productId = requireJobsRead(req);
|
||||
return repo.listJobDefinitions(productId);
|
||||
});
|
||||
|
||||
// Get a specific job definition
|
||||
app.get('/jobs/:id', async req => {
|
||||
await extractAuth(req);
|
||||
const productId = requireJobsRead(req);
|
||||
const { id } = req.params as { id: string };
|
||||
return repo.getJobDefinition(id, DEFAULT_PRODUCT_ID);
|
||||
return repo.getJobDefinition(id, productId);
|
||||
});
|
||||
|
||||
// Update job (enable/disable, change cron, etc.)
|
||||
app.put('/jobs/:id', async req => {
|
||||
await extractAuth(req);
|
||||
const productId = requireJobsWrite(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = UpdateJobSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
return repo.updateJobDefinition(id, DEFAULT_PRODUCT_ID, parsed.data);
|
||||
return repo.updateJobDefinition(id, productId, parsed.data);
|
||||
});
|
||||
|
||||
// Manually trigger a job
|
||||
app.post('/jobs/trigger', async req => {
|
||||
await extractAuth(req);
|
||||
const productId = requireJobsWrite(req);
|
||||
const parsed = TriggerJobSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
@ -47,16 +63,16 @@ export async function jobRoutes(app: FastifyInstance) {
|
||||
throw new BadRequestError(`No handler registered for job '${jobName}'`);
|
||||
}
|
||||
|
||||
const def = await repo.getJobDefinition(`job_${jobName}`, DEFAULT_PRODUCT_ID);
|
||||
const def = await repo.getJobDefinition(`job_${jobName}`, productId);
|
||||
const run = await executeJob(def, 'manual', req.log);
|
||||
return run;
|
||||
});
|
||||
|
||||
// List recent runs for a job
|
||||
app.get('/jobs/:name/runs', async req => {
|
||||
await extractAuth(req);
|
||||
const productId = requireJobsRead(req);
|
||||
const { name } = req.params as { name: string };
|
||||
const limit = parseInt((req.query as Record<string, string>).limit || '20', 10);
|
||||
return repo.listJobRuns(DEFAULT_PRODUCT_ID, name, Math.min(limit, 100));
|
||||
return repo.listJobRuns(productId, name, Math.min(limit, 100));
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
|
||||
import { requireJwtOrApiKey } from '../../lib/api-key-auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { UpdateMaintenanceSchema, CreateMaintenanceWindowSchema } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
@ -27,44 +28,53 @@ export async function maintenanceRoutes(app: FastifyInstance) {
|
||||
|
||||
// ── Admin endpoints ────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: import('fastify').FastifyRequest): string {
|
||||
const role = req.jwtPayload?.role;
|
||||
if (!role || !['super_admin', 'admin'].includes(role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const sub = req.jwtPayload?.sub;
|
||||
if (!sub) throw new UnauthorizedError('Missing sub in token');
|
||||
return sub;
|
||||
function requireMaintenanceRead(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['maintenance:read'],
|
||||
rateLimitKey: 'maintenance:read',
|
||||
});
|
||||
}
|
||||
|
||||
function requireMaintenanceWrite(req: import('fastify').FastifyRequest) {
|
||||
return requireJwtOrApiKey(req, {
|
||||
jwtRoles: ['super_admin', 'admin'],
|
||||
apiKeyScopes: ['maintenance:write'],
|
||||
rateLimitKey: 'maintenance:write',
|
||||
});
|
||||
}
|
||||
|
||||
// Get full maintenance config (admin sees bypass rules too)
|
||||
app.get('/settings/maintenance/full', async req => {
|
||||
requireAdmin(req);
|
||||
return repo.getMaintenanceConfig(DEFAULT_PRODUCT_ID);
|
||||
const access = requireMaintenanceRead(req);
|
||||
return repo.getMaintenanceConfig(access.productId);
|
||||
});
|
||||
|
||||
// Update maintenance mode
|
||||
app.put('/settings/maintenance', async req => {
|
||||
const adminId = requireAdmin(req);
|
||||
const access = requireMaintenanceWrite(req);
|
||||
const parsed = UpdateMaintenanceSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const config = await repo.updateMaintenanceConfig(DEFAULT_PRODUCT_ID, {
|
||||
const config = await repo.updateMaintenanceConfig(access.productId, {
|
||||
...parsed.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: adminId,
|
||||
updatedBy: access.actorId,
|
||||
});
|
||||
|
||||
req.log.info({ mode: config.mode, adminId }, `[maintenance] Mode changed to '${config.mode}'`);
|
||||
req.log.info(
|
||||
{ mode: config.mode, adminId: access.actorId },
|
||||
`[maintenance] Mode changed to '${config.mode}'`
|
||||
);
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Create a scheduled maintenance window
|
||||
app.post('/settings/maintenance/schedule', async (req, reply) => {
|
||||
const adminId = requireAdmin(req);
|
||||
const access = requireMaintenanceWrite(req);
|
||||
const parsed = CreateMaintenanceWindowSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
@ -76,14 +86,14 @@ export async function maintenanceRoutes(app: FastifyInstance) {
|
||||
|
||||
const window = await repo.createWindow({
|
||||
id: `mw_${crypto.randomUUID()}`,
|
||||
productId: DEFAULT_PRODUCT_ID,
|
||||
productId: access.productId,
|
||||
title: parsed.data.title,
|
||||
message: parsed.data.message,
|
||||
mode: parsed.data.mode,
|
||||
scheduledStart: parsed.data.scheduledStart,
|
||||
scheduledEnd: parsed.data.scheduledEnd,
|
||||
affectedServices: parsed.data.affectedServices,
|
||||
createdBy: adminId,
|
||||
createdBy: access.actorId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@ -92,9 +102,9 @@ export async function maintenanceRoutes(app: FastifyInstance) {
|
||||
|
||||
// Delete a scheduled maintenance window
|
||||
app.delete('/settings/maintenance/schedule/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const access = requireMaintenanceWrite(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const deleted = await repo.deleteWindow(id, DEFAULT_PRODUCT_ID);
|
||||
const deleted = await repo.deleteWindow(id, access.productId);
|
||||
if (!deleted) throw new BadRequestError('Maintenance window not found');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user