feat(api-key): restrict ops routes to service tokens

This commit is contained in:
root 2026-03-15 06:24:08 +00:00
parent d1b3faae8b
commit 8240f6060d
8 changed files with 296 additions and 3 deletions

View File

@ -142,6 +142,30 @@ describe('api key auth', () => {
expect(res.statusCode).toBe(403);
});
it('rejects api key when token type is not permitted for the route', async () => {
await seedApiKeyToken(['maintenance:read'], { tokenType: 'product_api' });
const app = Fastify();
await registerOptionalApiKeyContext(app);
app.get('/probe', async req => {
return requireJwtOrApiKey(req, {
apiKeyScopes: ['maintenance:read'],
apiKeyTokenTypes: ['service_api'],
});
});
const res = await app.inject({
method: 'GET',
url: '/probe',
headers: {
'x-api-key': rawApiKey,
},
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('Forbidden');
});
it('rejects api key when x-product-id targets another product', async () => {
await seedApiKeyToken(['jobs:read']);
const app = Fastify();

View File

@ -35,6 +35,7 @@ interface AccessOptions {
allowJwt?: boolean;
jwtRoles?: string[];
apiKeyScopes?: string[];
apiKeyTokenTypes?: ApiKeyAuthPayload['tokenType'][];
rateLimitKey?: string;
}
@ -132,7 +133,11 @@ function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' {
}
}
function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload {
function ensureApiKeyScopes(
req: FastifyRequest,
requiredScopes: string[] = [],
allowedTokenTypes?: ApiKeyAuthPayload['tokenType'][]
): ApiKeyAuthPayload {
const apiKey = req.apiKeyAuth;
if (!apiKey) {
throw new UnauthorizedError('API key required');
@ -152,6 +157,12 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []):
throw new ForbiddenError('API key missing required scopes');
}
if (allowedTokenTypes && allowedTokenTypes.length > 0) {
if (!allowedTokenTypes.includes(apiKey.tokenType)) {
throw new ForbiddenError('API key token type is not permitted for this route');
}
}
return apiKey;
}
@ -173,7 +184,7 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
export function requireJwtOrApiKey(
req: FastifyRequest,
{ allowJwt = false, jwtRoles, apiKeyScopes, rateLimitKey }: AccessOptions = {}
{ allowJwt = false, jwtRoles, apiKeyScopes, apiKeyTokenTypes, rateLimitKey }: AccessOptions = {}
): AccessActor {
const jwt = req.jwtPayload;
if (jwt?.sub) {
@ -192,7 +203,7 @@ export function requireJwtOrApiKey(
};
}
const apiKey = ensureApiKeyScopes(req, apiKeyScopes);
const apiKey = ensureApiKeyScopes(req, apiKeyScopes, apiKeyTokenTypes);
enforceApiKeyRateLimit(req, rateLimitKey);
return {

View File

@ -0,0 +1,83 @@
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_${'e'.repeat(64)}`;
const repoMock = {
createExportJob: vi.fn(),
listExportJobs: vi.fn(),
getExportJob: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
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');
await collection.create({
id: 'tok_exports_1',
productId: 'lysnrai',
userId: 'svc_exports',
userName: 'Export Service',
tokenType,
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',
scopes,
expiresAt: '2099-01-01T00:00:00.000Z',
lastUsed: null,
});
}
describe('exportRoutes api key integration', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
});
it('allows export reads via service_api keys', async () => {
await seedApiKey(['exports:read'], 'service_api');
repoMock.listExportJobs.mockResolvedValue([{ id: 'exp_1', productId: 'lysnrai' }]);
const { exportRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(exportRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/exports',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(200);
expect(repoMock.listExportJobs).toHaveBeenCalledWith('lysnrai', 20);
});
it('rejects product_api keys on export routes', async () => {
await seedApiKey(['exports:read'], 'product_api');
const { exportRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(exportRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/exports',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(403);
expect(repoMock.listExportJobs).not.toHaveBeenCalled();
});
});

View File

@ -10,6 +10,7 @@ export async function exportRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['exports:read'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'exports:read',
});
}
@ -18,6 +19,7 @@ export async function exportRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['exports:write'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'exports:write',
});
}

View File

@ -0,0 +1,84 @@
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_${'f'.repeat(64)}`;
const repoMock = {
listRules: vi.fn(),
createRule: vi.fn(),
deleteRule: vi.fn(),
checkIP: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
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');
await collection.create({
id: 'tok_ip_rules_1',
productId: 'lysnrai',
userId: 'svc_security',
userName: 'Security Service',
tokenType,
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',
scopes,
expiresAt: '2099-01-01T00:00:00.000Z',
lastUsed: null,
});
}
describe('ipRuleRoutes api key integration', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
});
it('allows ip rule reads via service_api keys', async () => {
await seedApiKey(['ip-rules:read'], 'service_api');
repoMock.listRules.mockResolvedValue([{ id: 'ipr_1', productId: 'lysnrai' }]);
const { ipRuleRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(ipRuleRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/ratelimit/ip-rules',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(200);
expect(repoMock.listRules).toHaveBeenCalledWith('lysnrai');
});
it('rejects product_api keys on ip rule routes', async () => {
await seedApiKey(['ip-rules:read'], 'product_api');
const { ipRuleRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(ipRuleRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/ratelimit/ip-rules',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(403);
expect(repoMock.listRules).not.toHaveBeenCalled();
});
});

View File

@ -10,6 +10,7 @@ export async function ipRuleRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['ip-rules:read'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'ip-rules:read',
});
}
@ -18,6 +19,7 @@ export async function ipRuleRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['ip-rules:write'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'ip-rules:write',
});
}

View File

@ -0,0 +1,85 @@
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_${'d'.repeat(64)}`;
const repoMock = {
getMaintenanceConfig: vi.fn(),
listUpcomingWindows: vi.fn(),
updateMaintenanceConfig: vi.fn(),
createWindow: vi.fn(),
deleteWindow: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
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');
await collection.create({
id: 'tok_maintenance_1',
productId: 'lysnrai',
userId: 'svc_ops',
userName: 'Ops Service',
tokenType,
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',
scopes,
expiresAt: '2099-01-01T00:00:00.000Z',
lastUsed: null,
});
}
describe('maintenanceRoutes api key integration', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
});
it('allows maintenance reads via service_api keys', async () => {
await seedApiKey(['maintenance:read'], 'service_api');
repoMock.getMaintenanceConfig.mockResolvedValue({ mode: 'off', productId: 'lysnrai' });
const { maintenanceRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(maintenanceRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/settings/maintenance/full',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(200);
expect(repoMock.getMaintenanceConfig).toHaveBeenCalledWith('lysnrai');
});
it('rejects product_api keys on maintenance admin routes', async () => {
await seedApiKey(['maintenance:read'], 'product_api');
const { maintenanceRoutes } = await import('./routes.js');
const app = Fastify();
await registerOptionalApiKeyContext(app);
await app.register(maintenanceRoutes, { prefix: '/api' });
const res = await app.inject({
method: 'GET',
url: '/api/settings/maintenance/full',
headers: { 'x-api-key': rawApiKey },
});
expect(res.statusCode).toBe(403);
expect(repoMock.getMaintenanceConfig).not.toHaveBeenCalled();
});
});

View File

@ -32,6 +32,7 @@ export async function maintenanceRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['maintenance:read'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'maintenance:read',
});
}
@ -40,6 +41,7 @@ export async function maintenanceRoutes(app: FastifyInstance) {
return requireJwtOrApiKey(req, {
jwtRoles: ['super_admin', 'admin'],
apiKeyScopes: ['maintenance:write'],
apiKeyTokenTypes: ['service_api'],
rateLimitKey: 'maintenance:write',
});
}