WP-01: tenancy, identity and capability authorization

Adds the data model for accounts, locations, teams, users, memberships
and scopes, plus roles, the 70-capability catalogue, database-backed
sessions, and the audit log.

Isolation is enforced twice, independently. A Prisma extension injects
accountId into every query, and PostgreSQL row-level security filters
underneath it, keyed on a transaction-local setting. The first alone
leaves raw queries unguarded; the second alone returns empty results
without saying why.

Integration tests prove both against a real database rather than
through the application layer, which would only prove the application
layer. They create a restricted role to do it — and that exposed a trap
worth naming: **a PostgreSQL superuser bypasses row-level security even
with FORCE**. Connecting the app as one silently disables the second
layer while every application test still passes. checkTenantIsolation
now refuses to start in production on such a database, warns in
development, and reports through /api/sante. The README explains the
role to create.

The audit log is append-only by trigger, so it resists even a
superuser: a trail that can be rewritten proves nothing. Entries
carrying an adjustment or an unlock are rejected without a
justification, and known secret-bearing fields are redacted before
writing — the log is read, exported and kept for years, so it must not
become a second unencrypted copy of what is encrypted elsewhere.

Sensitive columns use AES-256-GCM with the key held outside the
database. Sign-in verifies a dummy hash for unknown accounts so timing
does not enumerate addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 22:39:33 +00:00
1 parent 93852c2602
commit 11763142d5
16 files changed
+2037 -9

No files matched your search

+12 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { checkTenantIsolation } from '@/server/db-guard';
import { checkDatabase } from '@/server/health';
export const dynamic = 'force-dynamic';
@@ -14,8 +15,18 @@ export const dynamic = 'force-dynamic';
export async function GET() {
const database = await checkDatabase();
// L'isolation est signalée mais ne dégrade pas la sonde : une base joignable
// avec un compte trop privilégié reste une application qui répond. Le refus
// de démarrer, lui, est traité par assertTenantIsolation en production.
const isolation = database.ok
? await checkTenantIsolation().catch(() => null)
: null;
return NextResponse.json(
{ status: database.ok ? 'ok' : 'degraded' },
{
status: database.ok ? 'ok' : 'degraded',
tenantIsolation: isolation ? (isolation.ok ? 'enforced' : 'weakened') : 'unknown',
},
{
status: database.ok ? 200 : 503,
headers: { 'cache-control': 'no-store' },