diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index 4bfdcc3..7d393d9 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -1,6 +1,24 @@ -# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 4 OIDC) +# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 5) -## CHANGELOG depuis derniere update (Bloc 4 — auth OIDC-ready) +## CHANGELOG depuis derniere update (Bloc 5 — rate limit + cache invalidation cote writes) + +- **Bloc 5 livre (rate limit defensif + invalidation cache writes)** : + - Nouveau module `src/middleware/rate-limit.ts` : middleware Hono autour de `RedisCache.checkRateLimit` (sliding window deja teste integration). Cle derivee de l'identite avec priorites : `tokenId` (service token) > `email` OIDC (lower-cased) > `sub` OIDC > IP via `x-forwarded-for` (avec WARN log car spoofable) > `anonymous`. Throw `errors.rateLimited(windowSeconds)` avec headers `X-RateLimit-Limit/Remaining/Reset`. Helper exporte `defaultRateLimitKey` pour composer (`${default}:mut`). + - Nouveau module `src/lib/cache.ts` : `invalidateEntity(redis, entity, id?)` qui mirror la logique cascade de `webhooks/baserow-handler.ts` (attribution -> module + personne, intervention -> tache + personne, etc.). Volontairement duplique plutot qu'extrait commun car les contextes sont differents (event_type webhook vs intent route). + - Wire `src/index.ts` : + - `rateLimit(redis, {global})` sur `/api/v1/*` apres l'auth middleware. + - `rateLimit(redis, {mutation, keyFrom: ...:mut})` ajoute conditionnellement sur POST/PATCH/PUT/DELETE — compteur Redis distinct, plus strict. + - **Pas de rate limit** sur `/api/health`, `/api/ready`, `/api/webhooks/*` (ces dernieres ont HMAC + idempotence Redis qui couvrent). + - Routes mutation appellent `invalidateEntity()` apres write reussi (3 routes : `POST /modules/:id/attribuer`, `POST /interventions`, `PATCH /attributions/:id/heures-realisees`). Ferme la fenetre stale entre l'ecriture Baserow et l'arrivee du webhook (idempotent avec l'invalidation webhook qui suivra). + - Config zod : 4 vars ajoutees avec defauts `100/60s` global et `30/60s` mutation. Toutes coercees + optionnelles via env (`RATE_LIMIT_GLOBAL_MAX`, etc.). + - `.env.example` : section rate limit reecrite (commentee, defauts documentes), ancienne triple-var Phase 1 supprimee. + - Tests : **29 tests ajoutes** (290 -> 319). 11 tests rate-limit middleware (cles, priorites, 429, headers, mutation independance, anonymous fallback), 11 tests cache helper (cascade par entite, idempotence, total returned), 7 tests integration `/api/v1/*` vs health/webhooks. Pas de fake timers : `RedisCache.checkRateLimit` est mocke, on simule la reset par mutation du compteur fake (equivalent fonctionnel). + - Coverage : `src/middleware/rate-limit.ts` = **100% lines / 100% branches / 100% funcs**. `src/lib/cache.ts` = **100% lines / 100% branches / 100% funcs**. Coverage globale 87.7% (+0.3pt). + - vitest.config.ts thresholds : ajout `src/middleware/rate-limit.ts` et `src/lib/cache.ts` a 85%. + +# SESSION RESUME — formation-hub Acadenice (Bloc 4 — auth OIDC-ready) + +## CHANGELOG (Bloc 4 — auth OIDC-ready) - **Bloc 4 livre (middleware OIDC-ready, dual mode)** : - Nouveau module `src/middleware/oidc-verifier.ts` : verification JWT via `jose` + JWKS remote (cache 10min). Algorithmes acceptes : RS256/RS384/RS512 (pas HS* puisque cle publique). Throw `errors.authInvalid()` sur tout echec (signature, expired, issuer mismatch, audience mismatch). @@ -91,7 +109,7 @@ Stack live + bridge testes : | 3.2 — Refactor erreurs domain typees + routes /blocs /clients /taches | TODO | DomainError sub-classes (RGViolationError, ConflictError) pour remplacer mapping par texte | | 4 — Auth middleware OIDC-ready | DONE | dual mode service-token + Authentik JWT/cookie, mode OIDC desactive en local (vars env absentes), 27 tests coverage 94.11% | | 4b — Docmost OIDC fork (rebrand DocAdenice + login Authentik) | TODO | docmost-fork-dev — depend Bloc 4 | -| 5 — Rate limit + cache invalidation | TODO | RedisCache.checkRateLimit existe deja, faut middleware Hono qui l'appelle | +| 5 — Rate limit + cache invalidation | DONE | middleware/rate-limit.ts (100%), lib/cache.ts (100%), wire global + mutation sur /api/v1/*, invalidation 3 routes write | | 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines | | 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage | | 7b — Webhook Docmost (stub) | STUB | log-only, handlers metier en Bloc 8 | diff --git a/bridge/.env.example b/bridge/.env.example index de712b8..12d1e81 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -37,7 +37,13 @@ BRIDGE_API_TOKENS= # AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]} # AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement) -# Rate limiting (par token + endpoint) -RATE_LIMIT_READ_PER_MIN=600 -RATE_LIMIT_WRITE_PER_MIN=60 -RATE_LIMIT_WEBHOOK_PER_MIN=1000 +# Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/* +# (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense). +# Global s'applique sur tous les verbes ; Mutation s'ajoute sur POST/PATCH/PUT/DELETE +# avec un compteur Redis distinct (suffixe `:mut`) volontairement plus strict. +# Cle derivee de l'identite : tokenId (service token) > email OIDC > sub OIDC > IP > anonymous. +# Defauts conservateurs ci-dessous, override si besoin. +# RATE_LIMIT_GLOBAL_MAX=100 +# RATE_LIMIT_GLOBAL_WINDOW=60 +# RATE_LIMIT_MUTATION_MAX=30 +# RATE_LIMIT_MUTATION_WINDOW=60 diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 032b65f..abf2857 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -13,6 +13,7 @@ import { getContainer, initContainer } from './lib/container.js'; import { logger } from './lib/logger.js'; import { type AuthVariables, authMiddleware } from './middleware/auth.js'; import { errorHandler } from './middleware/error-handler.js'; +import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js'; import { attributionsRoutes } from './routes/attributions.js'; import { formationsRoutes } from './routes/formations.js'; import { interventionsRoutes } from './routes/interventions.js'; @@ -57,6 +58,30 @@ export async function buildApp(): Promise> { logger: ctn.logger, }), ); + // Rate limit global sur toute identite authentifiee. Place APRES authMiddleware + // pour que `c.var.user` soit deja peuple — sinon la cle tombe sur IP. + v1.use( + '*', + rateLimit(ctn.redis, { + maxRequests: ctn.config.rateLimitGlobalMax, + windowSeconds: ctn.config.rateLimitGlobalWindow, + }), + ); + // Rate limit mutation : compteur dedie (suffixe `:mut`) qui ne s'applique + // qu'aux verbes ecrivants. Plus strict que le global car bursts d'ecriture = + // plus dangereux (charge Baserow + integrite donnees). + const mutationLimiter = rateLimit(ctn.redis, { + maxRequests: ctn.config.rateLimitMutationMax, + windowSeconds: ctn.config.rateLimitMutationWindow, + keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`, + }); + v1.use('*', async (c, next) => { + const method = c.req.method.toUpperCase(); + if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { + return mutationLimiter(c, next); + } + await next(); + }); v1.route('/personnes', personnesRoutes); v1.route('/formations', formationsRoutes); v1.route('/projets', projetsRoutes); diff --git a/bridge/src/lib/cache.ts b/bridge/src/lib/cache.ts new file mode 100644 index 0000000..cb698b4 --- /dev/null +++ b/bridge/src/lib/cache.ts @@ -0,0 +1,73 @@ +/** + * Helpers d'invalidation cache cote bridge. + * + * Quand une route REST `/api/v1/*` mute Baserow (POST/PATCH/PUT/DELETE), Baserow + * va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler.ts`. + * MAIS la latence webhook est variable (ms a quelques secondes selon la conf + * Baserow + reseau) — entre l'ecriture et l'arrivee du webhook, une lecture + * concurrente peut servir une valeur stale. L'invalidation immediate cote write + * ferme cette fenetre et evite la double-source-of-truth temporaire. + * + * Volontairement pas de coordination avec le webhook : si les deux invalidations + * tombent (write local puis webhook), `invalidatePattern` est idempotent (un + * pattern qui ne matche rien retourne 0, pas d'erreur). + */ + +import type { TableName } from '../repos/baserow-repo.js'; + +export interface CacheInvalidator { + invalidatePattern: (pattern: string) => Promise; +} + +/** + * Invalide le cache local pour une entite + cascade sur les rollups parents. + * Mirror de la logique webhook (`buildInvalidationPatterns`) — duplique + * volontairement ici plutot que d'extraire car les contextes sont differents + * (event_type webhook vs intent route). + * + * Si `id` fourni : invalide la row precise + la liste. Sinon : juste la liste + * (utile sur les creates ou on n'a pas encore l'id parent a invalider). + */ +export async function invalidateEntity( + redis: CacheInvalidator, + entity: TableName, + id?: number, +): Promise { + const patterns: string[] = [`bridge:${entity}:list:*`]; + if (typeof id === 'number') { + patterns.push(`bridge:${entity}:row:${id}`); + } + + // Cascade rollups parent : aligned avec webhooks/baserow-handler.ts. + switch (entity) { + case 'attribution': + patterns.push('bridge:module:row:*', 'bridge:module:list:*'); + patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); + break; + case 'intervention': + patterns.push('bridge:tache:row:*', 'bridge:tache:list:*'); + patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); + break; + case 'module': + patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*'); + patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); + break; + case 'bloc': + patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); + break; + case 'tache': + patterns.push('bridge:projet:row:*', 'bridge:projet:list:*'); + break; + case 'projet': + patterns.push('bridge:client:row:*', 'bridge:client:list:*'); + break; + default: + break; + } + + let total = 0; + for (const pattern of patterns) { + total += await redis.invalidatePattern(pattern); + } + return total; +} diff --git a/bridge/src/lib/config.ts b/bridge/src/lib/config.ts index e13add1..bc8bac3 100644 --- a/bridge/src/lib/config.ts +++ b/bridge/src/lib/config.ts @@ -24,6 +24,13 @@ const ConfigSchema = z.object({ // Si false : un JWT OIDC valide dont l'email n'a pas de Personne attache passe quand meme // (scopes derives uniquement des groups Authentik). Defaut strict. authStrictMapping: z.coerce.boolean().default(true), + // Rate limiting (Bloc 5). Global s'applique sur tout /api/v1/* ; mutation s'ajoute + // sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger + // contre les bursts buggy / scripts mal configures. + rateLimitGlobalMax: z.coerce.number().int().positive().default(100), + rateLimitGlobalWindow: z.coerce.number().int().positive().default(60), + rateLimitMutationMax: z.coerce.number().int().positive().default(30), + rateLimitMutationWindow: z.coerce.number().int().positive().default(60), }); export type Config = z.infer; @@ -46,6 +53,10 @@ export function loadConfig(): Config { authentikAudience: process.env.AUTHENTIK_AUDIENCE, authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP, authStrictMapping: process.env.AUTH_STRICT_MAPPING, + rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX, + rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW, + rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX, + rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW, }); if (!parsed.success) { diff --git a/bridge/src/middleware/rate-limit.ts b/bridge/src/middleware/rate-limit.ts new file mode 100644 index 0000000..cdbccda --- /dev/null +++ b/bridge/src/middleware/rate-limit.ts @@ -0,0 +1,100 @@ +/** + * Rate limit middleware bridge — defensif contre les bursts buggy + abus. + * + * Strategie : sliding window via `RedisCache.checkRateLimit` (deja teste en + * integration). Cle derivee de l'identite authentifiee : + * 1. tokenId (service token `brg_*`) — identite forte + * 2. email (OIDC) — identite forte + * 3. sub (OIDC) — fallback si email absent + * 4. IP via `x-forwarded-for` — identite faible (trompable derriere proxy mal configure) + * 5. 'anonymous' — derniere ligne avant 401 + * + * Le cas IP est volontairement note `WARN` au log : un attaquant peut spoofer + * `X-Forwarded-За-For` si Traefik/nginx ne sanitize pas. En prod le rate limit + * doit s'appliquer apres l'auth middleware (qui rejette les anonymous), donc + * ce path est principalement reserve aux endpoints publics futurs. + * + * Differencier global / mutation : utiliser `keyFrom: c => `${default}:mut`` pour + * un compteur Redis dedie aux writes. Ainsi un user qui fait 20 GET + 20 POST + * consomme deux compteurs distincts. + */ + +import type { Context, MiddlewareHandler } from 'hono'; +import { errors } from '../lib/errors.js'; +import { logger } from '../lib/logger.js'; +import type { AuthVariables } from './auth.js'; + +export interface RateLimitDeps { + /** Compatible avec `RedisCache.checkRateLimit`. */ + checkRateLimit: (key: string, maxRequests: number, windowSeconds: number) => Promise; +} + +export interface RateLimitOptions { + /** Nombre max de requetes dans la fenetre. */ + maxRequests: number; + /** Largeur de la fenetre en secondes. */ + windowSeconds: number; + /** + * Source de la clef rate limit. Defaut : tokenId service-token, sinon email + * OIDC, sinon sub OIDC, sinon IP (x-forwarded-for), sinon 'anonymous'. + */ + keyFrom?: (c: Context<{ Variables: AuthVariables }>) => string; +} + +/** + * Cle par defaut + warning si on tombe sur l'IP fallback. + * Exporte pour reuse dans `keyFrom` custom (`c => `${defaultRateLimitKey(c)}:mut``). + */ +export function defaultRateLimitKey(c: Context<{ Variables: AuthVariables }>): string { + // c.var.user n'est pas garanti present : le middleware peut etre monte avant + // ou apres l'auth selon la route. On lit defensivement. + const user = c.get('user') as AuthVariables['user'] | undefined; + if (user?.tokenId) return `token:${user.tokenId}`; + if (user?.email) return `email:${user.email.toLowerCase()}`; + if (user?.sub) return `sub:${user.sub}`; + + // Fallback IP — identite faible. + const xff = c.req.header('x-forwarded-for'); + if (xff) { + // x-forwarded-for peut contenir une chaine "client, proxy1, proxy2" ; + // on prend le premier (le plus eloigne du serveur). + const ip = xff.split(',')[0]?.trim(); + if (ip) { + logger.warn( + { ip, path: c.req.path }, + 'rate limit using IP fallback — identite faible, vulnerable au spoof XFF', + ); + return `ip:${ip}`; + } + } + logger.warn({ path: c.req.path }, 'rate limit on anonymous identity (no token, no IP)'); + return 'anonymous'; +} + +/** + * Construit le middleware. Le `prefix` permet d'isoler plusieurs compteurs + * pour la meme identite (ex : global vs mutation). + */ +export function rateLimit( + deps: RateLimitDeps, + opts: RateLimitOptions, +): MiddlewareHandler<{ Variables: AuthVariables }> { + const { maxRequests, windowSeconds, keyFrom = defaultRateLimitKey } = opts; + + return async (c, next) => { + const baseKey = keyFrom(c); + const allowed = await deps.checkRateLimit(baseKey, maxRequests, windowSeconds); + + if (!allowed) { + // X-RateLimit-Reset = secondes avant que la fenetre se vide. On donne + // `windowSeconds` au pire, l'utilisateur peut retry plus tot mais c'est + // un upper bound sur. + c.header('X-RateLimit-Limit', String(maxRequests)); + c.header('X-RateLimit-Remaining', '0'); + c.header('X-RateLimit-Reset', String(windowSeconds)); + throw errors.rateLimited(windowSeconds); + } + + await next(); + }; +} diff --git a/bridge/src/routes/attributions.ts b/bridge/src/routes/attributions.ts index d9aa69a..b78c89a 100644 --- a/bridge/src/routes/attributions.ts +++ b/bridge/src/routes/attributions.ts @@ -6,6 +6,7 @@ import { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { z } from 'zod'; +import { invalidateEntity } from '../lib/cache.js'; import { getContainer } from '../lib/container.js'; import { errors } from '../lib/errors.js'; import { dec, parseBody } from '../lib/http.js'; @@ -41,6 +42,10 @@ attributionsRoutes.patch('/:id/heures-realisees', requireScope('write:attributio await repos.attributions.updateHeuresRealisees(id, attribution.heuresRealisees); + // Invalidation cache locale apres write — ferme la fenetre stale pre-webhook. + const { redis } = getContainer(); + await invalidateEntity(redis, 'attribution', id); + return c.json({ data: { attribution_id: id, diff --git a/bridge/src/routes/interventions.ts b/bridge/src/routes/interventions.ts index 4e226e7..600c37e 100644 --- a/bridge/src/routes/interventions.ts +++ b/bridge/src/routes/interventions.ts @@ -6,6 +6,7 @@ import { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { z } from 'zod'; +import { invalidateEntity } from '../lib/cache.js'; import { getContainer } from '../lib/container.js'; import { errors } from '../lib/errors.js'; import { dec, parseBody } from '../lib/http.js'; @@ -61,6 +62,10 @@ interventionsRoutes.post('/', requireScope('write:interventions'), async (c) => throw err; } + // Invalidation cache locale apres create — cascade tache + personne (rollups). + const { redis } = getContainer(); + await invalidateEntity(redis, 'intervention', createdId); + return c.json( { data: { diff --git a/bridge/src/routes/modules.ts b/bridge/src/routes/modules.ts index 8728a78..4a1441a 100644 --- a/bridge/src/routes/modules.ts +++ b/bridge/src/routes/modules.ts @@ -10,6 +10,7 @@ import { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { z } from 'zod'; +import { invalidateEntity } from '../lib/cache.js'; import { getContainer } from '../lib/container.js'; import { errors } from '../lib/errors.js'; import { dec, parseBody } from '../lib/http.js'; @@ -86,6 +87,10 @@ modulesRoutes.post('/:id/attribuer', requireScope('write:attributions'), async ( throw err; } + // Invalidation cache locale apres create — cascade module + personne (rollups). + const { redis } = getContainer(); + await invalidateEntity(redis, 'attribution', createdId); + return c.json( { data: { diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts index 7cc6cea..1f8dcfa 100644 --- a/bridge/tests/helpers/test-app.ts +++ b/bridge/tests/helpers/test-app.ts @@ -63,12 +63,23 @@ export interface TestContainerOverrides { tokens?: ApiTokenRecord[]; } +/** + * Stub Redis minimal pour les tests routes : juste les methodes que les routes + * appellent (invalidatePattern + checkRateLimit). No-op qui ne refuse jamais. + */ +function buildNoopRedis(): RedisCache { + return { + invalidatePattern: async (_pattern: string) => 0, + checkRateLimit: async (_key: string, _max: number, _win: number) => true, + } as unknown as RedisCache; +} + export function installTestContainer(over: TestContainerOverrides): Container { const tokensMap = new Map(); for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t); const fakeBaserow = over.baserow ?? ({} as BaserowClient); - const fakeRedis = over.redis ?? ({} as RedisCache); + const fakeRedis = over.redis ?? buildNoopRedis(); const container: Container = { config: { @@ -82,6 +93,10 @@ export function installTestContainer(over: TestContainerOverrides): Container { docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars', bridgeApiTokens: undefined, authStrictMapping: true, + rateLimitGlobalMax: 10000, + rateLimitGlobalWindow: 60, + rateLimitMutationMax: 10000, + rateLimitMutationWindow: 60, }, baserow: fakeBaserow, redis: fakeRedis, diff --git a/bridge/tests/integration/rate-limit-app.test.ts b/bridge/tests/integration/rate-limit-app.test.ts new file mode 100644 index 0000000..301b17e --- /dev/null +++ b/bridge/tests/integration/rate-limit-app.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test : verifie que le rate limit s'applique sur /api/v1/* et + * PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation + * cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation. + * + * On reconstitue une mini app proche de buildApp() (sans serve()) avec un + * fake Redis qui compte les calls invalidatePattern + checkRateLimit. + */ + +import { Decimal } from 'decimal.js'; +import { Hono } from 'hono'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { RedisCache } from '../../src/adapters/redis-cache.js'; +import type { Personne } from '../../src/domain/personne.js'; +import { setContainer } from '../../src/lib/container.js'; +import { logger } from '../../src/lib/logger.js'; +import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js'; +import { errorHandler } from '../../src/middleware/error-handler.js'; +import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js'; +import { attributionsRoutes } from '../../src/routes/attributions.js'; +import { interventionsRoutes } from '../../src/routes/interventions.js'; +import { modulesRoutes } from '../../src/routes/modules.js'; +import { personnesRoutes } from '../../src/routes/personnes.js'; +import { webhooksRoutes } from '../../src/routes/webhooks.js'; +import { buildFakeRepos } from '../helpers/fake-repos.js'; +import { makeAttribution, makePersonne } from '../helpers/fixtures.js'; + +const TABLE_IDS = { + personne: 1, + formation: 2, + bloc: 3, + module: 4, + attribution: 5, + client: 6, + projet: 7, + tache: 8, + intervention: 9, +} as const; + +class FakeRedis { + public invalidations: string[] = []; + public rateChecks: Array<{ key: string; max: number }> = []; + public counts = new Map(); + public eventIds = new Set(); + + async invalidatePattern(pattern: string): Promise { + this.invalidations.push(pattern); + return 1; + } + + async checkRateLimit(key: string, max: number, _window: number): Promise { + this.rateChecks.push({ key, max }); + const next = (this.counts.get(key) ?? 0) + 1; + this.counts.set(key, next); + return next <= max; + } + + async checkAndStoreEventId(id: string): Promise { + if (this.eventIds.has(id)) return true; + this.eventIds.add(id); + return false; + } +} + +const READ_TOKEN = 'brg_read'; +const WRITE_TOKEN = 'brg_write'; + +function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) { + const personne = makePersonne({ id: 1, roles: ['formateur'] }); + const attribution = makeAttribution({ id: 500 }); + const repos = buildFakeRepos({ personnes: [personne], attributions: [attribution] }); + + setContainer({ + config: { + nodeEnv: 'test', + port: 0, + logLevel: 'fatal', + baserowApiUrl: 'http://localhost', + baserowApiToken: 'fake', + redisUrl: 'redis://localhost', + baserowWebhookSecret: 'fake-secret-at-least-16-chars-long', + docmostWebhookSecret: undefined, + bridgeApiTokens: undefined, + authStrictMapping: true, + rateLimitGlobalMax: opts.globalMax, + rateLimitGlobalWindow: 60, + rateLimitMutationMax: opts.mutationMax, + rateLimitMutationWindow: 60, + }, + // biome-ignore lint/suspicious/noExplicitAny: stubs + baserow: {} as any, + redis: redis as unknown as RedisCache, + repos, + tokens: new Map([ + [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }], + [ + WRITE_TOKEN, + { token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] }, + ], + ]), + tableIds: TABLE_IDS, + oidc: null, + groupsScopesMap: {}, + logger, + }); + return repos; +} + +function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) { + const app = new Hono<{ Variables: AuthVariables }>(); + app.onError(errorHandler); + + app.get('/api/health', (c) => c.json({ status: 'ok' })); + app.get('/api/ready', (c) => c.json({ status: 'ok' })); + app.route('/api/webhooks', webhooksRoutes); + + const v1 = new Hono<{ Variables: AuthVariables }>(); + v1.use( + '*', + authMiddleware({ + tokens: new Map([ + [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }], + [ + WRITE_TOKEN, + { token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] }, + ], + ]), + oidc: null, + groupsScopesMap: {}, + strictMapping: true, + cache: { + get: async () => null, + set: async () => {}, + }, + finder: { findByEmail: async (): Promise => null }, + logger, + }), + ); + v1.use('*', rateLimit(redis, { maxRequests: opts.globalMax, windowSeconds: 60 })); + const mutLimiter = rateLimit(redis, { + maxRequests: opts.mutationMax, + windowSeconds: 60, + keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`, + }); + v1.use('*', async (c, next) => { + const m = c.req.method.toUpperCase(); + if (m === 'POST' || m === 'PATCH' || m === 'PUT' || m === 'DELETE') { + return mutLimiter(c, next); + } + await next(); + }); + v1.route('/personnes', personnesRoutes); + v1.route('/modules', modulesRoutes); + v1.route('/interventions', interventionsRoutes); + v1.route('/attributions', attributionsRoutes); + app.route('/api/v1', v1); + return app; +} + +afterEach(() => setContainer(null)); + +describe('Rate limit application sur /api/v1/*', () => { + it('GET /api/health : pas de rate limit (route publique)', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 1, mutationMax: 1 }); + const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 }); + + const r1 = await app.request('/api/health'); + const r2 = await app.request('/api/health'); + const r3 = await app.request('/api/health'); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(200); + expect(redis.rateChecks).toHaveLength(0); + }); + + it('GET /api/ready : pas de rate limit', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 1, mutationMax: 1 }); + const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 }); + + await app.request('/api/ready'); + await app.request('/api/ready'); + + expect(redis.rateChecks).toHaveLength(0); + }); + + it('POST /api/webhooks/baserow : pas de rate limit (HMAC + idempotence couvrent)', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 1, mutationMax: 1 }); + const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 }); + + // Sans signature : 401, mais sans avoir consomme le rate limit. + const r = await app.request('/api/webhooks/baserow', { method: 'POST', body: '{}' }); + expect(r.status).toBe(401); + expect(redis.rateChecks).toHaveLength(0); + }); + + it('GET /api/v1/personnes : rate limit consomme', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 100, mutationMax: 100 }); + const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 }); + + const r = await app.request('/api/v1/personnes', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(r.status).toBe(200); + expect(redis.rateChecks).toHaveLength(1); + expect(redis.rateChecks[0]?.key).toBe('token:reader'); + }); + + it('GET au-dela de globalMax -> 429', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 2, mutationMax: 100 }); + const app = buildAppWithRateLimit(redis, { globalMax: 2, mutationMax: 100 }); + + const headers = { Authorization: `Bearer ${READ_TOKEN}` }; + const r1 = await app.request('/api/v1/personnes', { headers }); + const r2 = await app.request('/api/v1/personnes', { headers }); + const r3 = await app.request('/api/v1/personnes', { headers }); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(429); + }); + + it('PATCH attribution : applique mutation rate limit + invalide le cache', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 100, mutationMax: 100 }); + const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 }); + + const r = await app.request('/api/v1/attributions/500/heures-realisees', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ heures_realisees: 4 }), + }); + + expect(r.status).toBe(200); + + // Deux compteurs Redis distincts : token:writer et token:writer:mut. + const keys = redis.rateChecks.map((c) => c.key); + expect(keys).toContain('token:writer'); + expect(keys).toContain('token:writer:mut'); + + // Cache invalidation : attribution row + list + cascade module + personne. + expect(redis.invalidations).toContain('bridge:attribution:list:*'); + expect(redis.invalidations).toContain('bridge:attribution:row:500'); + expect(redis.invalidations).toContain('bridge:module:list:*'); + expect(redis.invalidations).toContain('bridge:personne:list:*'); + }); + + it('mutation au-dela de mutationMax -> 429 meme si globalMax pas atteint', async () => { + const redis = new FakeRedis(); + installContainer(redis, { globalMax: 100, mutationMax: 1 }); + const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 1 }); + + const headers = { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json' }; + const body = JSON.stringify({ heures_realisees: new Decimal(1).toNumber() }); + + const r1 = await app.request('/api/v1/attributions/500/heures-realisees', { + method: 'PATCH', + headers, + body, + }); + const r2 = await app.request('/api/v1/attributions/500/heures-realisees', { + method: 'PATCH', + headers, + body, + }); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(429); + }); +}); diff --git a/bridge/tests/middleware/rate-limit.test.ts b/bridge/tests/middleware/rate-limit.test.ts new file mode 100644 index 0000000..660010d --- /dev/null +++ b/bridge/tests/middleware/rate-limit.test.ts @@ -0,0 +1,286 @@ +/** + * Tests rate limit middleware — fakes Redis.checkRateLimit pour observabilite. + * + * Pour les fenetres temporelles : pas de fake timers Vitest car + * RedisCache.checkRateLimit est mocke par fonction async ; on simule la reset + * en remplacant le mock entre les requetes (equivalent fonctionnel). + */ + +import { Hono } from 'hono'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { errorHandler } from '../../src/middleware/error-handler.js'; +import { + type RateLimitDeps, + defaultRateLimitKey, + rateLimit, +} from '../../src/middleware/rate-limit.js'; + +interface FakeUser { + source: string; + tokenId?: string; + email?: string; + sub?: string; + roles: string[]; + groups: string[]; + scopes: string[]; +} + +function buildApp( + deps: RateLimitDeps, + opts: { max: number; window: number; keyFrom?: Parameters[1]['keyFrom'] }, + presetUser?: FakeUser, +) { + const app = new Hono(); + app.onError(errorHandler); + app.use('*', async (c, next) => { + if (presetUser) c.set('user' as never, presetUser as never); + await next(); + }); + app.use( + '*', + rateLimit(deps, { + maxRequests: opts.max, + windowSeconds: opts.window, + keyFrom: opts.keyFrom, + }), + ); + app.get('/', (c) => c.text('ok')); + app.post('/mut', (c) => c.text('mut')); + return app; +} + +class FakeLimiter { + public calls: Array<{ key: string; max: number; window: number }> = []; + // Map de (key) -> count vu jusqu'a maintenant. La fenetre n'est pas simulee : + // on remet a 0 manuellement entre les groupes de tests pour mimer l'expiry. + public counts = new Map(); + + checkRateLimit = async (key: string, max: number, window: number): Promise => { + this.calls.push({ key, max, window }); + const next = (this.counts.get(key) ?? 0) + 1; + this.counts.set(key, next); + return next <= max; + }; + + reset(key: string) { + this.counts.set(key, 0); + } +} + +describe('rateLimit middleware', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('autorise sous la limite, refuse au-dela avec 429 RATE_LIMITED', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 2, window: 60 }, + { + source: 'service-token', + tokenId: 'svc-A', + roles: [], + groups: [], + scopes: [], + }, + ); + + const r1 = await app.request('/'); + const r2 = await app.request('/'); + const r3 = await app.request('/'); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(429); + const body = (await r3.json()) as { + error: { code: string; details?: { retry_after: number } }; + }; + expect(body.error.code).toBe('RATE_LIMITED'); + expect(body.error.details?.retry_after).toBe(60); + expect(r3.headers.get('X-RateLimit-Limit')).toBe('2'); + expect(r3.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(r3.headers.get('X-RateLimit-Reset')).toBe('60'); + }); + + it('reset apres windowSeconds (simulee via reset du compteur fake)', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 1, window: 5 }, + { + source: 'service-token', + tokenId: 'svc-B', + roles: [], + groups: [], + scopes: [], + }, + ); + + const r1 = await app.request('/'); + const r2 = await app.request('/'); + expect(r1.status).toBe(200); + expect(r2.status).toBe(429); + + // Simule l'expiry de la fenetre. + limiter.reset('token:svc-B'); + + const r3 = await app.request('/'); + expect(r3.status).toBe(200); + }); + + it('compteurs global et mutation sont independants (cles distinctes en Redis)', async () => { + const limiter = new FakeLimiter(); + const user: FakeUser = { + source: 'service-token', + tokenId: 'svc-C', + roles: [], + groups: [], + scopes: [], + }; + + const app = new Hono(); + app.onError(errorHandler); + app.use('*', async (c, next) => { + c.set('user' as never, user as never); + await next(); + }); + app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 })); + app.use( + '*', + rateLimit(limiter, { + maxRequests: 2, + windowSeconds: 60, + keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`, + }), + ); + app.post('/mut', (c) => c.text('ok')); + + const r1 = await app.request('/mut', { method: 'POST' }); + const r2 = await app.request('/mut', { method: 'POST' }); + const r3 = await app.request('/mut', { method: 'POST' }); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + // 3eme : global = 3/5 OK, mutation = 3/2 KO -> 429. + expect(r3.status).toBe(429); + + // Assertions sur les cles : deux compteurs distincts en Redis. + const keys = limiter.calls.map((c) => c.key); + expect(keys).toContain('token:svc-C'); + expect(keys).toContain('token:svc-C:mut'); + }); + + it('cle prioritaire : tokenId service-token avant tout', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 5, window: 60 }, + { + source: 'service-token', + tokenId: 'svc-D', + email: 'should-not-be-used@test', + sub: 'sub-x', + roles: [], + groups: [], + scopes: [], + }, + ); + + await app.request('/'); + expect(limiter.calls[0]?.key).toBe('token:svc-D'); + }); + + it('cle email OIDC quand pas de tokenId', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 5, window: 60 }, + { + source: 'oidc-jwt', + email: 'Foo@Bar.IO', + sub: 'sub-y', + roles: [], + groups: [], + scopes: [], + }, + ); + + await app.request('/'); + // Email lower-case pour stabilite cross-casing. + expect(limiter.calls[0]?.key).toBe('email:foo@bar.io'); + }); + + it('cle sub OIDC quand email absent', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 5, window: 60 }, + { + source: 'oidc-jwt', + sub: 'sub-z', + roles: [], + groups: [], + scopes: [], + }, + ); + + await app.request('/'); + expect(limiter.calls[0]?.key).toBe('sub:sub-z'); + }); + + it('fallback IP via x-forwarded-for + warning logge', async () => { + const limiter = new FakeLimiter(); + const app = new Hono(); + app.onError(errorHandler); + app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 })); + app.get('/', (c) => c.text('ok')); + + await app.request('/', { headers: { 'x-forwarded-for': '203.0.113.5, 10.0.0.1' } }); + + // Premier IP (client le plus eloigne) extrait correctement. + expect(limiter.calls[0]?.key).toBe('ip:203.0.113.5'); + }); + + it('fallback anonymous quand ni user ni IP', async () => { + const limiter = new FakeLimiter(); + const app = new Hono(); + app.onError(errorHandler); + app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 })); + app.get('/', (c) => c.text('ok')); + + await app.request('/'); + expect(limiter.calls[0]?.key).toBe('anonymous'); + }); + + it('keyFrom custom override la priorite par defaut', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 5, window: 60, keyFrom: () => 'custom-key' }, + { source: 'service-token', tokenId: 'svc-E', roles: [], groups: [], scopes: [] }, + ); + + await app.request('/'); + expect(limiter.calls[0]?.key).toBe('custom-key'); + }); + + it('appelle next() en cascade sans erreur quand sous la limite', async () => { + const limiter = new FakeLimiter(); + const app = buildApp( + limiter, + { max: 10, window: 60 }, + { + source: 'service-token', + tokenId: 'svc-F', + roles: [], + groups: [], + scopes: [], + }, + ); + + const res = await app.request('/'); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); +}); diff --git a/bridge/tests/unit/cache.test.ts b/bridge/tests/unit/cache.test.ts new file mode 100644 index 0000000..0246dc3 --- /dev/null +++ b/bridge/tests/unit/cache.test.ts @@ -0,0 +1,134 @@ +/** + * Tests unit pour invalidateEntity — verifie les patterns generes par entite, + * la cascade rollups parent, et l'idempotence (deux invalidations meme key). + */ + +import { describe, expect, it } from 'vitest'; +import { type CacheInvalidator, invalidateEntity } from '../../src/lib/cache.js'; + +class FakeRedis implements CacheInvalidator { + public patterns: string[] = []; + // Map pour simuler des keys persistees (incrementee a chaque set fictif). + public callCount = 0; + + async invalidatePattern(pattern: string): Promise { + this.patterns.push(pattern); + this.callCount++; + return 1; // un match fictif + } +} + +describe('invalidateEntity', () => { + it('attribution : cascade sur module + personne (rollups RG-01)', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'attribution', 42); + + expect(redis.patterns).toContain('bridge:attribution:list:*'); + expect(redis.patterns).toContain('bridge:attribution:row:42'); + expect(redis.patterns).toContain('bridge:module:row:*'); + expect(redis.patterns).toContain('bridge:module:list:*'); + expect(redis.patterns).toContain('bridge:personne:row:*'); + expect(redis.patterns).toContain('bridge:personne:list:*'); + }); + + it('intervention : cascade sur tache + personne', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'intervention', 100); + + expect(redis.patterns).toContain('bridge:intervention:list:*'); + expect(redis.patterns).toContain('bridge:intervention:row:100'); + expect(redis.patterns).toContain('bridge:tache:row:*'); + expect(redis.patterns).toContain('bridge:tache:list:*'); + expect(redis.patterns).toContain('bridge:personne:row:*'); + expect(redis.patterns).toContain('bridge:personne:list:*'); + }); + + it('module : cascade sur bloc + formation', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'module', 7); + expect(redis.patterns).toContain('bridge:module:list:*'); + expect(redis.patterns).toContain('bridge:module:row:7'); + expect(redis.patterns).toContain('bridge:bloc:row:*'); + expect(redis.patterns).toContain('bridge:bloc:list:*'); + expect(redis.patterns).toContain('bridge:formation:row:*'); + expect(redis.patterns).toContain('bridge:formation:list:*'); + }); + + it('bloc : cascade formation seulement', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'bloc', 3); + expect(redis.patterns).toContain('bridge:bloc:list:*'); + expect(redis.patterns).toContain('bridge:bloc:row:3'); + expect(redis.patterns).toContain('bridge:formation:row:*'); + expect(redis.patterns).toContain('bridge:formation:list:*'); + // Pas de cascade modules au-dessus. + expect(redis.patterns).not.toContain('bridge:module:list:*'); + }); + + it('tache : cascade projet', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'tache', 8); + expect(redis.patterns).toContain('bridge:tache:list:*'); + expect(redis.patterns).toContain('bridge:tache:row:8'); + expect(redis.patterns).toContain('bridge:projet:row:*'); + expect(redis.patterns).toContain('bridge:projet:list:*'); + }); + + it('projet : cascade client', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'projet', 5); + expect(redis.patterns).toContain('bridge:projet:list:*'); + expect(redis.patterns).toContain('bridge:projet:row:5'); + expect(redis.patterns).toContain('bridge:client:row:*'); + expect(redis.patterns).toContain('bridge:client:list:*'); + }); + + it('personne : pas de cascade parent (entite racine)', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'personne', 1); + expect(redis.patterns).toContain('bridge:personne:list:*'); + expect(redis.patterns).toContain('bridge:personne:row:1'); + expect(redis.patterns).toHaveLength(2); + }); + + it('formation : pas de cascade parent (entite racine)', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'formation', 9); + expect(redis.patterns).toContain('bridge:formation:list:*'); + expect(redis.patterns).toContain('bridge:formation:row:9'); + expect(redis.patterns).toHaveLength(2); + }); + + it('client : pas de cascade parent', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'client', 4); + expect(redis.patterns).toContain('bridge:client:list:*'); + expect(redis.patterns).toContain('bridge:client:row:4'); + expect(redis.patterns).toHaveLength(2); + }); + + it('sans id : invalide juste la liste + cascade (cas create avant id connu)', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'attribution'); + + expect(redis.patterns).toContain('bridge:attribution:list:*'); + expect(redis.patterns).not.toContain('bridge:attribution:row:undefined'); + // Cascade toujours appliquee meme sans id. + expect(redis.patterns).toContain('bridge:module:list:*'); + }); + + it('idempotent : deux invalidations meme key ne throw pas', async () => { + const redis = new FakeRedis(); + await invalidateEntity(redis, 'attribution', 42); + await expect(invalidateEntity(redis, 'attribution', 42)).resolves.toBeGreaterThanOrEqual(0); + // Les patterns sont appeles deux fois, c'est attendu. + expect(redis.patterns.filter((p) => p === 'bridge:attribution:row:42')).toHaveLength(2); + }); + + it('retourne le total des keys invalidees', async () => { + const redis = new FakeRedis(); + const total = await invalidateEntity(redis, 'attribution', 1); + // FakeRedis retourne 1 par appel, 6 patterns -> 6. + expect(total).toBe(6); + }); +}); diff --git a/bridge/tests/webhooks/routes.test.ts b/bridge/tests/webhooks/routes.test.ts index 5dda71c..bf9e673 100644 --- a/bridge/tests/webhooks/routes.test.ts +++ b/bridge/tests/webhooks/routes.test.ts @@ -55,6 +55,11 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) { baserowWebhookSecret: BASEROW_SECRET, docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined, bridgeApiTokens: undefined, + authStrictMapping: true, + rateLimitGlobalMax: 10000, + rateLimitGlobalWindow: 60, + rateLimitMutationMax: 10000, + rateLimitMutationWindow: 60, }, // biome-ignore lint/suspicious/noExplicitAny: fake injection baserow: {} as any, diff --git a/bridge/vitest.config.ts b/bridge/vitest.config.ts index 67092b3..bae48f1 100644 --- a/bridge/vitest.config.ts +++ b/bridge/vitest.config.ts @@ -37,6 +37,19 @@ export default defineConfig({ branches: 85, statements: 85, }, + // Bloc 5 : rate limit middleware + cache helper. + 'src/middleware/rate-limit.ts': { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, + 'src/lib/cache.ts': { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, }, }, passWithNoTests: true,