Wiki/bridge/src/middleware/auth.ts
Corentin JOGUET c8e9b4d4ea
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
feat(bridge): bloc 3 — routes REST Tier 1 + auth + repos Baserow (10 endpoints)
Wiring HTTP du bridge service. 10 endpoints livres (cf docs/19 §6.1-6.5) :
- GET /api/v1/personnes (+ /:id, + /:id/dashboard)
- GET /api/v1/formations (+ /:id avec rollups blocs/modules)
- GET /api/v1/projets (+ /:id avec rollups taches)
- POST /api/v1/modules/:id/attribuer (RG-01 -> 422, role/heures invalides -> 400)
- POST /api/v1/interventions (validation role developpeur + heures > 0)
- PATCH /api/v1/attributions/:id/heures-realisees (409 si annule/realise)

Layers ajoutees :
- src/middleware/auth.ts : Bearer brg_*, scopes JSON-encoded BRIDGE_API_TOKENS, admin:* wildcard
- src/middleware/error-handler.ts : BridgeError -> JSON shape standard
- src/lib/container.ts : DI singleton (Baserow + Redis + 9 repos), setContainer testable
- src/lib/http.ts : parseListQuery + parseBody zod helper
- src/repos/baserow-repo.ts : BaseRepo<T> abstrait + 9 sous-classes (mapping Row<->Domain)
- src/routes/{personnes,formations,projets,modules,interventions,attributions}.ts

src/index.ts reecrit : buildApp() + initContainer + auth sur /api/v1/* + ready check Baserow+Redis.

Tests : 163/163 verts (12 suites domain + 8 nouvelles : auth, repos, 6 routes).
Coverage src global : 70.77% (cible 60%). Domain 97.86%, routes 96%, middleware 86%.

Choix : BaseRepo abstrait (pas mega-generic, Ockham) ; FakeRepos in-memory pour tests routes
(pas de testcontainers ici, c'est Bloc 7) ; mapping erreurs domain -> HTTP par message texte
(fragile, sera refactor en DomainError typees au Bloc 3.2).

Hors scope (a venir) :
- Bloc 5 : rate limiting Redis
- Bloc 7 : webhook handlers Baserow + sync bidirec + cache invalidation
- Bloc 3.2 : routes /docmost/*, /sync/*, /rapports/*

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:01:36 +02:00

109 lines
3.4 KiB
TypeScript

/**
* Auth middleware bridge — API tokens longue duree (`brg_*`) avec scopes.
*
* Tokens declares dans `BRIDGE_API_TOKENS` au format JSON :
* [{"token":"brg_xxx","name":"docmost-prod","scopes":["read:personnes","write:attributions"]}]
*
* JSON choisi plutot qu'un mini-DSL `name:scope1,scope2;...` : parse natif, pas d'ambiguite
* sur les separateurs si un nom contient virgule/point-virgule. Cf rapport Bloc 3.
*/
import type { MiddlewareHandler } from 'hono';
import { errors } from '../lib/errors.js';
export interface ApiTokenRecord {
token: string;
name: string;
scopes: string[];
}
export interface AuthContext {
tokenName: string;
scopes: ReadonlySet<string>;
}
/** Hono context variable map — augmente sur l'app pour acces type-safe. */
export type AuthVariables = {
auth: AuthContext;
};
/** Parse `BRIDGE_API_TOKENS` (JSON). Retourne map token → record. */
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
const map = new Map<string, ApiTokenRecord>();
if (!raw || raw.trim().length === 0) return map;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('BRIDGE_API_TOKENS: JSON invalide');
}
if (!Array.isArray(parsed)) {
throw new Error('BRIDGE_API_TOKENS: doit etre un tableau JSON');
}
for (const entry of parsed) {
if (typeof entry !== 'object' || entry === null) {
throw new Error('BRIDGE_API_TOKENS: entrees doivent etre des objets');
}
const e = entry as Record<string, unknown>;
if (typeof e.token !== 'string' || typeof e.name !== 'string') {
throw new Error('BRIDGE_API_TOKENS: chaque entree requiert token + name');
}
if (!Array.isArray(e.scopes) || e.scopes.some((s: unknown) => typeof s !== 'string')) {
throw new Error('BRIDGE_API_TOKENS: scopes doit etre un tableau de strings');
}
map.set(e.token, { token: e.token, name: e.name, scopes: e.scopes as string[] });
}
return map;
}
/**
* Verifie si un set de scopes possedes couvre la demande.
* `admin:*` couvre tout. Match exact sinon.
*/
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
if (owned.has('admin:*')) return true;
return owned.has(required);
}
/**
* Factory middleware : exige un scope precis.
* Le middleware d'auth global doit avoir tourne avant pour peupler `c.var.auth`.
*/
export function requireScope(scope: string): MiddlewareHandler<{ Variables: AuthVariables }> {
return async (c, next) => {
const auth = c.get('auth');
if (!auth) {
throw errors.authRequired();
}
if (!hasScope(auth.scopes, scope)) {
throw errors.forbidden(scope);
}
await next();
};
}
/**
* Middleware d'auth global. Lit `Authorization: Bearer brg_*`, peuple `c.var.auth`.
* Pas d'enforcement de scope ici — c'est le job de `requireScope`.
*/
export function authMiddleware(
tokens: ReadonlyMap<string, ApiTokenRecord>,
): MiddlewareHandler<{ Variables: AuthVariables }> {
return async (c, next) => {
const header = c.req.header('Authorization');
if (!header) {
throw errors.authRequired();
}
const match = header.match(/^Bearer\s+(.+)$/);
if (!match) {
throw errors.authInvalid();
}
const token = match[1].trim();
const record = tokens.get(token);
if (!record) {
throw errors.authInvalid();
}
c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) });
await next();
};
}