Files
planflow/prisma/migrations/20260807230000_rls_and_audit_immutability/migration.sql
T
Claude 11763142d5 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
2026-08-07 22:39:33 +00:00

81 lines
3.2 KiB
PL/PgSQL

-- Défense en profondeur — PLAN.md §3.1 et §3.5.
--
-- L'extension Prisma filtre déjà chaque requête par compte. Ces règles-ci
-- valent pour le cas où elle serait contournée : requête brute, bug de
-- câblage, ou console de maintenance. Une isolation qui repose sur une seule
-- couche applicative n'est pas une isolation.
-- ---------------------------------------------------------------------------
-- 1. Journal d'audit append-only
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION planflow_deny_write() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION
'La table % est en append-only : ni UPDATE ni DELETE. Une correction s''écrit, elle ne se réécrit pas.',
TG_TABLE_NAME
USING ERRCODE = 'restrict_violation';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON "AuditLog"
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
-- ---------------------------------------------------------------------------
-- 2. Row-level security, portée par le compte courant
-- ---------------------------------------------------------------------------
--
-- Le compte est transmis par `SET LOCAL app.account_id` à l'ouverture d'une
-- transaction (voir src/server/tenant.ts). Hors transaction scopée, le réglage
-- est absent et les politiques ne laissent rien passer.
--
-- current_setting(..., true) renvoie NULL plutôt que d'échouer quand le
-- réglage n'existe pas : c'est ce qui permet à la politique de refuser au lieu
-- de faire planter la requête avec une erreur peu parlante.
CREATE OR REPLACE FUNCTION planflow_current_account() RETURNS text AS $$
SELECT NULLIF(current_setting('app.account_id', true), '');
$$ LANGUAGE sql STABLE;
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'Location', 'Team', 'Membership', 'MembershipScope', 'Invitation',
'Role', 'AuditLog', 'RetentionPolicy', 'FeatureFlag'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
-- Account lui-même : lecture limitée au compte courant.
ALTER TABLE "Account" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Account" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Account"
USING (id = planflow_current_account());
CREATE POLICY tenant_insert ON "Account"
FOR INSERT WITH CHECK (true);
-- ---------------------------------------------------------------------------
-- 3. Rôle applicatif
-- ---------------------------------------------------------------------------
--
-- Le propriétaire d'une table contourne la RLS, sauf FORCE ci-dessus. FORCE
-- l'applique aussi au propriétaire, donc les migrations et les tâches
-- d'administration doivent passer explicitement par un compte à privilèges.
-- On ne crée pas de rôle séparé ici : le déploiement auto-hébergé n'en a qu'un,
-- et FORCE suffit à rendre les politiques effectives.