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
81 lines
3.2 KiB
PL/PgSQL
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.
|