feat(api-key): restrict job operations to service tokens

This commit is contained in:
root 2026-03-15 09:08:38 +00:00
parent 95261acb92
commit 2f7163b856
4 changed files with 52 additions and 4 deletions

View File

@ -26,7 +26,10 @@ vi.mock('./repository.js', () => repoMock);
vi.mock('./registry.js', () => registryMock);
vi.mock('./runner.js', () => runnerMock);
async function seedApiKey(scopes: string[]) {
async function seedApiKey(
scopes: string[],
tokenType: 'product_api' | 'service_api' = 'service_api'
) {
const provider = new MemoryDatastoreProvider();
setProvider(provider);
const collection = provider.getCollection('api_tokens', '/id');
@ -35,7 +38,7 @@ async function seedApiKey(scopes: string[]) {
productId: 'lysnrai',
userId: 'svc_jobs',
userName: 'Jobs Service',
tokenType: 'service_api',
tokenType,
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
@ -97,4 +100,23 @@ describe('jobRoutes api key integration', () => {
expect(repoMock.getJobDefinition).toHaveBeenCalledWith('job_reindex', 'lysnrai');
expect(runnerMock.executeJob).toHaveBeenCalled();
});
it('rejects product_api keys on job routes', async () => {
await seedApiKey(['jobs:read'], 'product_api');
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(403);
expect(repoMock.listJobDefinitions).not.toHaveBeenCalled();
});
});

View File

@ -11,6 +11,7 @@ export async function jobRoutes(app: FastifyInstance) {
const access = requireJwtOrApiKey(req, {
allowJwt: true,
apiKeyScopes: ['jobs:read'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'jobs:read',
});
return access.productId;
@ -20,6 +21,7 @@ export async function jobRoutes(app: FastifyInstance) {
const access = requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['jobs:write'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'jobs:write',
});
return access.productId;

View File

@ -16,6 +16,7 @@ export async function runRoutes(app: FastifyInstance) {
const access = requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['jobs:read'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'jobs:read',
});
return access.productId;
@ -27,6 +28,7 @@ export async function runRoutes(app: FastifyInstance) {
} {
const access = requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'jobs:write',
});
return { productId: access.productId, actorId: access.actorId };

View File

@ -15,7 +15,10 @@ const repoMock = {
vi.mock('./repository.js', () => repoMock);
async function seedApiKey(scopes: string[]) {
async function seedApiKey(
scopes: string[],
tokenType: 'product_api' | 'service_api' = 'service_api'
) {
const provider = new MemoryDatastoreProvider();
setProvider(provider);
const collection = provider.getCollection('api_tokens', '/id');
@ -24,7 +27,7 @@ async function seedApiKey(scopes: string[]) {
productId: 'lysnrai',
userId: 'svc_runs',
userName: 'Runs Service',
tokenType: 'service_api',
tokenType,
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
@ -59,4 +62,23 @@ describe('runRoutes api key integration', () => {
expect(res.statusCode).toBe(200);
expect(repoMock.listRuns).toHaveBeenCalledWith('lysnrai', { limit: 10 });
});
it('rejects product_api keys on run routes', async () => {
await seedApiKey(['jobs:read'], 'product_api');
repoMock.listRuns.mockResolvedValue([{ id: 'run_1', productId: 'lysnrai' }]);
const { runRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(runRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/runs?limit=10',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(403);
expect(repoMock.listRuns).not.toHaveBeenCalled();
});
});