feat(api-key): add per-product throttling

This commit is contained in:
root 2026-03-15 06:26:42 +00:00
parent 8240f6060d
commit 57abfa5b03
2 changed files with 107 additions and 12 deletions

View File

@ -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);

View File

@ -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),
});
}
}