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 { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||||
import { setProvider } from './datastore.js';
|
import { setProvider } from './datastore.js';
|
||||||
import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-auth.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 rawApiKey = `wai_${'a'.repeat(64)}`;
|
||||||
|
const secondRawApiKey = `wai_${'b'.repeat(64)}`;
|
||||||
|
let provider: MemoryDatastoreProvider;
|
||||||
|
|
||||||
async function seedApiKeyToken(
|
async function seedApiKeyToken(
|
||||||
scopes: string[] = ['jobs:read'],
|
scopes: string[] = ['jobs:read'],
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
rawKey: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
tokenType: 'user_api' | 'product_api' | 'service_api';
|
tokenType: 'user_api' | 'product_api' | 'service_api';
|
||||||
environment: 'dev' | 'staging' | 'prod';
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
productId: string;
|
productId: string;
|
||||||
}> = {}
|
}> = {}
|
||||||
) {
|
) {
|
||||||
const provider = new MemoryDatastoreProvider();
|
|
||||||
setProvider(provider);
|
|
||||||
const collection = provider.getCollection('api_tokens', '/id');
|
const collection = provider.getCollection('api_tokens', '/id');
|
||||||
await collection.create({
|
await collection.create({
|
||||||
id: 'tok_api_1',
|
id: overrides.id ?? 'tok_api_1',
|
||||||
productId: overrides.productId ?? 'lysnrai',
|
productId: overrides.productId ?? 'lysnrai',
|
||||||
userId: 'svc_jobs',
|
userId: overrides.userId ?? 'svc_jobs',
|
||||||
userName: 'Jobs Service',
|
userName: overrides.userName ?? 'Jobs Service',
|
||||||
tokenType: overrides.tokenType ?? 'service_api',
|
tokenType: overrides.tokenType ?? 'service_api',
|
||||||
environment: overrides.environment ?? 'dev',
|
environment: overrides.environment ?? 'dev',
|
||||||
prefix: rawApiKey.slice(0, 12),
|
prefix: (overrides.rawKey ?? rawApiKey).slice(0, 12),
|
||||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
tokenHash: await bcrypt.hash(overrides.rawKey ?? rawApiKey, 10),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
scopes,
|
scopes,
|
||||||
expiresAt: '2099-01-01T00:00:00.000Z',
|
expiresAt: '2099-01-01T00:00:00.000Z',
|
||||||
@ -36,8 +41,12 @@ async function seedApiKeyToken(
|
|||||||
|
|
||||||
describe('api key auth', () => {
|
describe('api key auth', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
provider = new MemoryDatastoreProvider();
|
||||||
|
setProvider(provider);
|
||||||
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
|
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;
|
delete process.env.PLATFORM_RUNTIME_ENV;
|
||||||
|
clearRateLimits();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attaches apiKeyAuth from x-api-key', async () => {
|
it('attaches apiKeyAuth from x-api-key', async () => {
|
||||||
@ -224,6 +233,58 @@ describe('api key auth', () => {
|
|||||||
expect(second.statusCode).toBe(429);
|
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 () => {
|
it('allows JWT callers when configured', async () => {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
await registerOptionalApiKeyContext(app);
|
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 {
|
function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean {
|
||||||
if (grantedScopes.includes('*')) return true;
|
if (grantedScopes.includes('*')) return true;
|
||||||
|
|
||||||
@ -170,14 +193,25 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
|
|||||||
if (!rateLimitKey || !req.apiKeyAuth) return;
|
if (!rateLimitKey || !req.apiKeyAuth) return;
|
||||||
|
|
||||||
const apiKeyRateLimitConfig = loadApiKeyRateLimitConfig();
|
const apiKeyRateLimitConfig = loadApiKeyRateLimitConfig();
|
||||||
const rule = apiKeyRateLimitConfig[rateLimitKey];
|
const keyRule = apiKeyRateLimitConfig[rateLimitKey];
|
||||||
if (!rule) return;
|
if (!keyRule) return;
|
||||||
|
|
||||||
const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`;
|
const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`;
|
||||||
const result = rateLimitStore.checkAndRecord(compositeKey, rule);
|
const keyResult = rateLimitStore.checkAndRecord(compositeKey, keyRule);
|
||||||
if (!result.allowed) {
|
if (!keyResult.allowed) {
|
||||||
throw new TooManyRequestsError('API key rate limit exceeded', {
|
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