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

This commit is contained in:
root 2026-03-14 14:58:08 +00:00
parent 0ad6703961
commit da744ab116
5 changed files with 205 additions and 71 deletions

View File

@ -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;
});

View File

@ -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 };
});
}

View File

@ -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();
});
});

View File

@ -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));
});
}

View File

@ -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 };
});