feat(api-key): add per-product throttling
This commit is contained in:
parent
8240f6060d
commit
57abfa5b03
@ -4,29 +4,34 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider } from './datastore.js';
|
||||
import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-auth.js';
|
||||
import { clearAll as clearRateLimits } from '../modules/ratelimit/store.js';
|
||||
|
||||
const rawApiKey = `wai_${'a'.repeat(64)}`;
|
||||
const secondRawApiKey = `wai_${'b'.repeat(64)}`;
|
||||
let provider: MemoryDatastoreProvider;
|
||||
|
||||
async function seedApiKeyToken(
|
||||
scopes: string[] = ['jobs:read'],
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
rawKey: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
tokenType: 'user_api' | 'product_api' | 'service_api';
|
||||
environment: 'dev' | 'staging' | 'prod';
|
||||
productId: string;
|
||||
}> = {}
|
||||
) {
|
||||
const provider = new MemoryDatastoreProvider();
|
||||
setProvider(provider);
|
||||
const collection = provider.getCollection('api_tokens', '/id');
|
||||
await collection.create({
|
||||
id: 'tok_api_1',
|
||||
id: overrides.id ?? 'tok_api_1',
|
||||
productId: overrides.productId ?? 'lysnrai',
|
||||
userId: 'svc_jobs',
|
||||
userName: 'Jobs Service',
|
||||
userId: overrides.userId ?? 'svc_jobs',
|
||||
userName: overrides.userName ?? 'Jobs Service',
|
||||
tokenType: overrides.tokenType ?? 'service_api',
|
||||
environment: overrides.environment ?? 'dev',
|
||||
prefix: rawApiKey.slice(0, 12),
|
||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
||||
prefix: (overrides.rawKey ?? rawApiKey).slice(0, 12),
|
||||
tokenHash: await bcrypt.hash(overrides.rawKey ?? rawApiKey, 10),
|
||||
status: 'active',
|
||||
scopes,
|
||||
expiresAt: '2099-01-01T00:00:00.000Z',
|
||||
@ -36,8 +41,12 @@ async function seedApiKeyToken(
|
||||
|
||||
describe('api key auth', () => {
|
||||
beforeEach(() => {
|
||||
provider = new MemoryDatastoreProvider();
|
||||
setProvider(provider);
|
||||
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
|
||||
delete process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON;
|
||||
delete process.env.PLATFORM_RUNTIME_ENV;
|
||||
clearRateLimits();
|
||||
});
|
||||
|
||||
it('attaches apiKeyAuth from x-api-key', async () => {
|
||||
@ -224,6 +233,58 @@ describe('api key auth', () => {
|
||||
expect(second.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('rate limits across api keys for the same product and action key', async () => {
|
||||
process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({
|
||||
'jobs:write': {
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
});
|
||||
process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON = JSON.stringify({
|
||||
'jobs:write': {
|
||||
maxRequests: 1,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
});
|
||||
|
||||
await seedApiKeyToken(['jobs:write'], {
|
||||
id: 'tok_api_1',
|
||||
rawKey: rawApiKey,
|
||||
userId: 'svc_jobs_primary',
|
||||
userName: 'Jobs Primary',
|
||||
});
|
||||
await seedApiKeyToken(['jobs:write'], {
|
||||
id: 'tok_api_2',
|
||||
rawKey: secondRawApiKey,
|
||||
userId: 'svc_jobs_secondary',
|
||||
userName: 'Jobs Secondary',
|
||||
});
|
||||
|
||||
const app = Fastify();
|
||||
await registerOptionalApiKeyContext(app);
|
||||
|
||||
app.post('/probe', async req => {
|
||||
return requireJwtOrApiKey(req, {
|
||||
apiKeyScopes: ['jobs:write'],
|
||||
rateLimitKey: 'jobs:write',
|
||||
});
|
||||
});
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/probe',
|
||||
headers: { 'x-api-key': rawApiKey },
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/probe',
|
||||
headers: { 'x-api-key': secondRawApiKey },
|
||||
});
|
||||
expect(second.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('allows JWT callers when configured', async () => {
|
||||
const app = Fastify();
|
||||
await registerOptionalApiKeyContext(app);
|
||||
|
||||
@ -104,6 +104,29 @@ function loadApiKeyRateLimitConfig(): ApiKeyRateLimitConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function loadApiKeyProductRateLimitConfig(
|
||||
baseConfig: ApiKeyRateLimitConfig
|
||||
): ApiKeyRateLimitConfig {
|
||||
const raw = process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON;
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw) as ApiKeyRateLimitConfig;
|
||||
} catch {
|
||||
/* fall back to derived defaults */
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseConfig).map(([key, rule]) => [
|
||||
key,
|
||||
{
|
||||
maxRequests: Math.max(rule.maxRequests * 5, rule.maxRequests),
|
||||
windowSeconds: rule.windowSeconds,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean {
|
||||
if (grantedScopes.includes('*')) return true;
|
||||
|
||||
@ -170,14 +193,25 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
|
||||
if (!rateLimitKey || !req.apiKeyAuth) return;
|
||||
|
||||
const apiKeyRateLimitConfig = loadApiKeyRateLimitConfig();
|
||||
const rule = apiKeyRateLimitConfig[rateLimitKey];
|
||||
if (!rule) return;
|
||||
const keyRule = apiKeyRateLimitConfig[rateLimitKey];
|
||||
if (!keyRule) return;
|
||||
|
||||
const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`;
|
||||
const result = rateLimitStore.checkAndRecord(compositeKey, rule);
|
||||
if (!result.allowed) {
|
||||
const keyResult = rateLimitStore.checkAndRecord(compositeKey, keyRule);
|
||||
if (!keyResult.allowed) {
|
||||
throw new TooManyRequestsError('API key rate limit exceeded', {
|
||||
retryAfter: Math.ceil((result.retryAfterMs ?? 0) / 1000),
|
||||
retryAfter: Math.ceil((keyResult.retryAfterMs ?? 0) / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
const productRule = loadApiKeyProductRateLimitConfig(apiKeyRateLimitConfig)[rateLimitKey];
|
||||
if (!productRule) return;
|
||||
|
||||
const productCompositeKey = `api-key-product:${req.apiKeyAuth.productId}:${rateLimitKey}`;
|
||||
const productResult = rateLimitStore.checkAndRecord(productCompositeKey, productRule);
|
||||
if (!productResult.allowed) {
|
||||
throw new TooManyRequestsError('Product API key rate limit exceeded', {
|
||||
retryAfter: Math.ceil((productResult.retryAfterMs ?? 0) / 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user