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);
|
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 () => {
|
it('rejects api key when x-product-id targets another product', async () => {
|
||||||
await seedApiKeyToken(['jobs:read']);
|
await seedApiKeyToken(['jobs:read']);
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
|||||||
@ -35,6 +35,7 @@ interface AccessOptions {
|
|||||||
allowJwt?: boolean;
|
allowJwt?: boolean;
|
||||||
jwtRoles?: string[];
|
jwtRoles?: string[];
|
||||||
apiKeyScopes?: string[];
|
apiKeyScopes?: string[];
|
||||||
|
apiKeyTokenTypes?: ApiKeyAuthPayload['tokenType'][];
|
||||||
rateLimitKey?: string;
|
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;
|
const apiKey = req.apiKeyAuth;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new UnauthorizedError('API key required');
|
throw new UnauthorizedError('API key required');
|
||||||
@ -152,6 +157,12 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []):
|
|||||||
throw new ForbiddenError('API key missing required scopes');
|
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;
|
return apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +184,7 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
|
|||||||
|
|
||||||
export function requireJwtOrApiKey(
|
export function requireJwtOrApiKey(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
{ allowJwt = false, jwtRoles, apiKeyScopes, rateLimitKey }: AccessOptions = {}
|
{ allowJwt = false, jwtRoles, apiKeyScopes, apiKeyTokenTypes, rateLimitKey }: AccessOptions = {}
|
||||||
): AccessActor {
|
): AccessActor {
|
||||||
const jwt = req.jwtPayload;
|
const jwt = req.jwtPayload;
|
||||||
if (jwt?.sub) {
|
if (jwt?.sub) {
|
||||||
@ -192,7 +203,7 @@ export function requireJwtOrApiKey(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = ensureApiKeyScopes(req, apiKeyScopes);
|
const apiKey = ensureApiKeyScopes(req, apiKeyScopes, apiKeyTokenTypes);
|
||||||
enforceApiKeyRateLimit(req, rateLimitKey);
|
enforceApiKeyRateLimit(req, rateLimitKey);
|
||||||
|
|
||||||
return {
|
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, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['exports:read'],
|
apiKeyScopes: ['exports:read'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'exports:read',
|
rateLimitKey: 'exports:read',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -18,6 +19,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
return requireJwtOrApiKey(req, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['exports:write'],
|
apiKeyScopes: ['exports:write'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'exports:write',
|
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, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['ip-rules:read'],
|
apiKeyScopes: ['ip-rules:read'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'ip-rules:read',
|
rateLimitKey: 'ip-rules:read',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -18,6 +19,7 @@ export async function ipRuleRoutes(app: FastifyInstance) {
|
|||||||
return requireJwtOrApiKey(req, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['ip-rules:write'],
|
apiKeyScopes: ['ip-rules:write'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'ip-rules:write',
|
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, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['maintenance:read'],
|
apiKeyScopes: ['maintenance:read'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'maintenance:read',
|
rateLimitKey: 'maintenance:read',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -40,6 +41,7 @@ export async function maintenanceRoutes(app: FastifyInstance) {
|
|||||||
return requireJwtOrApiKey(req, {
|
return requireJwtOrApiKey(req, {
|
||||||
jwtRoles: ['super_admin', 'admin'],
|
jwtRoles: ['super_admin', 'admin'],
|
||||||
apiKeyScopes: ['maintenance:write'],
|
apiKeyScopes: ['maintenance:write'],
|
||||||
|
apiKeyTokenTypes: ['service_api'],
|
||||||
rateLimitKey: 'maintenance:write',
|
rateLimitKey: 'maintenance:write',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user