From da744ab1166d4104e85aec358336e664af5c180f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 14:58:08 +0000 Subject: [PATCH] feat(platform-service): allow scoped api keys on ops routes --- .../src/modules/exports/routes.ts | 41 ++++---- .../src/modules/ip-rules/routes.ts | 45 +++++---- .../src/modules/jobs/jobs.api-key.test.ts | 98 +++++++++++++++++++ .../src/modules/jobs/routes.ts | 42 +++++--- .../src/modules/maintenance/routes.ts | 50 ++++++---- 5 files changed, 205 insertions(+), 71 deletions(-) create mode 100644 services/platform-service/src/modules/jobs/jobs.api-key.test.ts diff --git a/services/platform-service/src/modules/exports/routes.ts b/services/platform-service/src/modules/exports/routes.ts index 1c347830..f6f12778 100644 --- a/services/platform-service/src/modules/exports/routes.ts +++ b/services/platform-service/src/modules/exports/routes.ts @@ -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; 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; }); diff --git a/services/platform-service/src/modules/ip-rules/routes.ts b/services/platform-service/src/modules/ip-rules/routes.ts index 8d371884..77c1a8c4 100644 --- a/services/platform-service/src/modules/ip-rules/routes.ts +++ b/services/platform-service/src/modules/ip-rules/routes.ts @@ -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 }; }); } diff --git a/services/platform-service/src/modules/jobs/jobs.api-key.test.ts b/services/platform-service/src/modules/jobs/jobs.api-key.test.ts new file mode 100644 index 00000000..179fbaac --- /dev/null +++ b/services/platform-service/src/modules/jobs/jobs.api-key.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/jobs/routes.ts b/services/platform-service/src/modules/jobs/routes.ts index 03d9ed4c..d8456e1c 100644 --- a/services/platform-service/src/modules/jobs/routes.ts +++ b/services/platform-service/src/modules/jobs/routes.ts @@ -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).limit || '20', 10); - return repo.listJobRuns(DEFAULT_PRODUCT_ID, name, Math.min(limit, 100)); + return repo.listJobRuns(productId, name, Math.min(limit, 100)); }); } diff --git a/services/platform-service/src/modules/maintenance/routes.ts b/services/platform-service/src/modules/maintenance/routes.ts index 0b3bdfab..7bf8a79d 100644 --- a/services/platform-service/src/modules/maintenance/routes.ts +++ b/services/platform-service/src/modules/maintenance/routes.ts @@ -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 }; });