feat(api-key): restrict ops routes to service tokens
This commit is contained in:
parent
d1b3faae8b
commit
8240f6060d
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user