Skip to content

Commit 1df54a9

Browse files
committed
fet: update RLS
1 parent 72283cb commit 1df54a9

4 files changed

Lines changed: 156 additions & 9 deletions

File tree

sandbox/postgres/architecture_decision_records.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ Un audit a identifié un gap structurel : `GRANT SELECT ON ALL TABLES` en Sectio
179179
donnait à `marius_user` un accès direct aux tables physiques sensibles, contournant
180180
les vues contrôlées. Le RLS sur 3 tables ne fermait pas ce vecteur.
181181

182-
Dix tables reçoivent un `REVOKE SELECT FROM marius_user` en Section 13 :
182+
Dix tables et une vue reçoivent un `REVOKE SELECT FROM marius_user` en Section 13 :
183183

184184
| Table | Données sensibles | Interface contrôlée |
185185
| ----------------------------- | ------------------------------------------ | ------------------------- |
@@ -193,6 +193,7 @@ Dix tables reçoivent un `REVOKE SELECT FROM marius_user` en Section 13 :
193193
| `content.body` | Corps HTML complet de tous docs | `content.v_article` |
194194
| `content.revision` | Snapshots éditoriaux complets | `content.v_article` |
195195
| `org.org_legal` | DUNS, SIRET, TVA — identifiants légaux | `marius_admin` uniquement |
196+
| `identity.v_auth` (vue) | Hash argon2id via BYPASSRLS vue | Middleware auth (postgres) |
196197

197198
**Deux mécanismes distincts, à ne pas confondre.**
198199

@@ -223,6 +224,7 @@ mécanismes de nature différente :
223224
| `content.body` | `REVOKE SELECT` | Erreur 42501 — accès refusé |
224225
| `content.revision` | `REVOKE SELECT` | Erreur 42501 — accès refusé |
225226
| `org.org_legal` | `REVOKE SELECT` | Erreur 42501 — accès refusé |
227+
| `identity.v_auth` (vue) | `REVOKE SELECT` | Erreur 42501 — accès refusé |
226228
| `content.core` | RLS (politiques actives) | Résultat filtré selon GUC |
227229
| `commerce.transaction_core` | RLS (politiques actives) | Résultat filtré selon GUC |
228230
| `identity.account_core` | RLS (politiques actives) | Résultat filtré selon GUC |
@@ -234,7 +236,7 @@ Core n'est pas évalué sur le chemin de lecture via ces vues. Le filtre d'accè
234236
est implémenté dans le WHERE de chaque vue concernée, via les helpers GUC. Le RLS
235237
physique reste actif pour les accès directs aux tables Core (défense en profondeur).
236238

237-
**Conséquence pratique** : les tables avec `REVOKE SELECT` n'ont pas de politique
239+
**Conséquence pratique** : les tables et vues avec `REVOKE SELECT` n'ont pas de politique
238240
RLS et n'en ont pas besoin — elles sont structurellement inaccessibles. Ajouter
239241
du RLS dessus serait redondant et trompeur (laisserait croire que le RLS est le
240242
mécanisme protecteur alors que c'est le REVOKE).
@@ -305,11 +307,24 @@ ne soit jamais évalué. L'accès applicatif est contraint aux vues sémantiques
305307
`content.v_article_list` et `content.v_article`, qui imposent la jointure sur
306308
`content.core` filtré par RLS.
307309

310+
**Extension aux vues (audit RLS global).**
311+
Le même vecteur s'applique aux vues owned par `postgres` (BYPASSRLS) : une vue
312+
peut lire des tables sous REVOKE SELECT et en exposer les données sans filtre.
313+
`identity.v_auth` illustrait ce cas — la vue exposait `password_hash` à tout
314+
`marius_user` en contournant le REVOKE sur `identity.auth`. Correction :
315+
`REVOKE SELECT ON identity.v_auth FROM marius_user`.
316+
`identity.v_person` illustre un second vecteur : email et phone issus de
317+
`identity.person_contact` (REVOKE'd) étaient projetés sans filtre. Correction :
318+
exclusion des colonnes PII de la projection de la vue.
319+
308320
**Invariant de maintenance.**
309321
Lors de tout ajout d'un composant satellite dans un schéma dont le Core est sous
310322
RLS, le `GRANT SELECT` hérité de `GRANT SELECT ON ALL TABLES` doit être
311323
immédiatement suivi d'un `REVOKE SELECT` ciblé ou d'une politique RLS dédiée.
312324
L'absence de protection est silencieuse — PostgreSQL n'émet aucun avertissement.
325+
La même vérification s'applique aux vues : toute vue projetant des données issues
326+
d'une table sous REVOKE SELECT doit recevoir soit un REVOKE SELECT propre, soit
327+
exclure les colonnes sensibles de sa projection.
313328

314329
### Invariant 2 — Security context des vues et responsabilité du contrôle d'accès
315330

@@ -475,6 +490,25 @@ publier un article mais pas créer le tag manquant pour l'indexer. `contributor`
475490
`author` ne reçoivent pas `manage_tags` — la taxonomie est un domaine structurel
476491
distinct de la création de contenu.
477492

493+
### Bits sans enforcement moteur (signaux applicatifs)
494+
495+
Neuf bits sont définis dans `identity.permission_bit` mais ne font l'objet d'aucun
496+
guard AOT ni politique RLS dans ce blueprint. Leur enforcement est délégué à la couche
497+
applicative (middleware, panneau d'administration). Cette délégation est intentionnelle
498+
dans tous les cas ci-dessous.
499+
500+
| Bit | Valeur | Motif de la délégation applicative |
501+
| --- | ------ | ---------------------------------- |
502+
| `access_admin` (0) | 1 | Accès au panneau admin — notion applicative pure, pas de table moteur associée |
503+
| `edit_comments` (6) | 64 | Pas de procédure `edit_comment` dans ce blueprint ; DML révoqué sur `content.comment` (ADR-020) |
504+
| `delete_comments` (7) | 128 | Même motif que `edit_comments` |
505+
| `manage_groups` (9) | 512 | Aucune procédure de gestion des groupes dans ce blueprint — réservé pour usage futur |
506+
| `manage_contents` (10) | 1024 | Uniquement détenu par `administrator`, qui possède déjà `edit_others_contents`. Bit sémantiquement distinct (accès section admin éditoriale) mais sans frontière de privilège moteur supplémentaire |
507+
| `manage_menus` (12) | 4096 | Aucune table de menus dans ce blueprint — réservé pour usage futur |
508+
| `upload_files` (13) | 8192 | Contrôle délégué au service de stockage (S3/CDN) — le moteur PostgreSQL ne gère pas les uploads |
509+
| `can_read` (14) | 16384 | Bit de présence minimale ; les données publiques sont lisibles sans RLS — pas de guard redondant |
510+
| `export_data` (20) | 1048576 | Aucune procédure d'export dans ce blueprint — réservé pour jobs ETL/RGPD futurs |
511+
478512
---
479513

480514
## ADR-005 — Fragmentation ECS : SoA en lieu de AoS

sandbox/postgres/master_schema_ddl.pgsql

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,7 +1656,15 @@ WHERE (
16561656
OR (identity.rls_auth_bits() & 256) = 256 -- manage_users
16571657
);
16581658

1659-
-- IDENTITY : v_person — schema.org/Person (profil public complet)
1659+
-- IDENTITY : v_person — schema.org/Person (profil public)
1660+
-- Colonnes PII retirées de la projection (audit RLS global) :
1661+
-- email, phone/telephone, fax — REVOKE SELECT sur identity.person_contact ;
1662+
-- la vue étant owned par postgres (BYPASSRLS), elle pouvait lire person_contact
1663+
-- malgré le REVOKE, exposant email/téléphone à tout marius_user.
1664+
-- url (site web) conservé : donnée de contact intentionnellement publique.
1665+
-- address_id (place_id) conservé : référence géographique, pas de PII directe.
1666+
-- Accès aux données de contact (email, phone) : réservé aux sessions manage_users
1667+
-- via connexion marius_admin ou procédure SECURITY DEFINER dédiée.
16601668
CREATE VIEW identity.v_person AS
16611669
SELECT
16621670
e.id AS identifier,
@@ -1673,8 +1681,6 @@ SELECT
16731681
pb.birth_place_id,
16741682
pb.death_date,
16751683
pb.death_place_id,
1676-
pc.email,
1677-
pc.phone AS telephone,
16781684
pc.url,
16791685
pc.place_id AS address_id,
16801686
pco.media_id AS image_id,
@@ -1996,6 +2002,14 @@ REVOKE SELECT ON commerce.transaction_item FROM marius_user;
19962002
-- que rls_core_select ne soit jamais évalué.
19972003
-- Interface contrôlée : content.v_article_list, content.v_article.
19982004

2005+
-- Gap documenté et accepté : content.content_to_tag et content.content_to_media
2006+
-- sont accessibles en SELECT direct (pas de REVOKE, pas de RLS propre). Un SELECT
2007+
-- sur ces tables de liaison révèle quels tags/médias sont associés à des brouillons.
2008+
-- Les tags eux-mêmes sont publics — seule la liaison brouillon+tag est exposée, pas
2009+
-- le contenu du brouillon. Risque évalué faible. Le correctif (REVOKE) casserait
2010+
-- v_tag_tree (article_count via content_to_tag) sans gain de sécurité significatif.
2011+
-- Référence : audit RLS global, ADR-029 invariant 1 note de limitation.
2012+
19992013
-- content.identity : headline, slug, description — métadonnées de tous les documents.
20002014
REVOKE SELECT ON content.identity FROM marius_user;
20012015

@@ -2010,6 +2024,13 @@ REVOKE SELECT ON content.revision FROM marius_user;
20102024
-- ADR-029 inv.1 : satellite d'org.org_core, accessible directement sans ce REVOKE.
20112025
REVOKE SELECT ON org.org_legal FROM marius_user;
20122026

2027+
-- identity.v_auth : hash argon2id, is_banned, role_id — interface d'authentification.
2028+
-- Usage réservé au middleware d'authentification via connexion postgres ou fonction
2029+
-- SECURITY DEFINER dédiée. La vue étant owned par postgres (BYPASSRLS), elle lit
2030+
-- identity.auth malgré le REVOKE SELECT sur la table physique. Sans ce REVOKE sur
2031+
-- la vue, tout marius_user peut lire les hashes de mots de passe de tous les comptes.
2032+
REVOKE SELECT ON identity.v_auth FROM marius_user;
2033+
20132034
-- USAGE séquences : permet currval() et inspection — les nextval() des procédures
20142035
-- passent via SECURITY DEFINER (owner = postgres) et n'en ont pas besoin.
20152036
GRANT USAGE ON ALL SEQUENCES IN SCHEMA identity TO marius_user;
@@ -2175,8 +2196,9 @@ ALTER PROCEDURE commerce.create_transaction_item(integer, integer, integer)
21752196
-- en tant que postgres et doivent pouvoir écrire sans restriction (ADR-020).
21762197
-- Le RLS sécurise le chemin de LECTURE (SELECT sur vues par marius_user).
21772198
-- Les tables les plus sensibles (identity.auth, person_contact, transaction_payment,
2178-
-- transaction_delivery) ont vu leur SELECT révoqué en Section 13 : le RLS est une
2179-
-- défense complémentaire, pas le seul verrou sur ces données.
2199+
-- transaction_delivery) ont vu leur SELECT révoqué en Section 13. La vue identity.v_auth
2200+
-- a également un REVOKE SELECT (audit RLS global) : password_hash ne doit jamais être
2201+
-- accessible à marius_user, même via la vue. Le RLS est une défense complémentaire.
21802202
-- ==============================================================================
21812203

21822204
-- Helper functions — évitent de répéter le COALESCE/casting dans chaque politique.

sandbox/postgres/tests/01_schema_and_security.sql

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
BEGIN;
1616

17-
SELECT plan(42);
17+
SELECT plan(46);
1818

1919

2020
-- ============================================================
@@ -312,6 +312,43 @@ SELECT throws_ok(
312312
RESET ROLE;
313313

314314

315+
316+
SELECT ok(
317+
COALESCE((
318+
SELECT p.prosecdef FROM pg_proc p
319+
JOIN pg_namespace n ON n.oid = p.pronamespace
320+
WHERE n.nspname = 'identity' AND p.proname = 'anonymize_person' AND p.prokind = 'p'
321+
), false),
322+
'identity.anonymize_person : SECURITY DEFINER (ADR-020)'
323+
);
324+
325+
SELECT ok(
326+
COALESCE((
327+
SELECT p.prosecdef FROM pg_proc p
328+
JOIN pg_namespace n ON n.oid = p.pronamespace
329+
WHERE n.nspname = 'org' AND p.proname = 'add_organization_to_hierarchy' AND p.prokind = 'p'
330+
), false),
331+
'org.add_organization_to_hierarchy : SECURITY DEFINER (ADR-020)'
332+
);
333+
334+
SELECT ok(
335+
COALESCE((
336+
SELECT p.prosecdef FROM pg_proc p
337+
JOIN pg_namespace n ON n.oid = p.pronamespace
338+
WHERE n.nspname = 'content' AND p.proname = 'add_tag_to_document' AND p.prokind = 'p'
339+
), false),
340+
'content.add_tag_to_document : SECURITY DEFINER (ADR-020)'
341+
);
342+
343+
SELECT ok(
344+
COALESCE((
345+
SELECT p.prosecdef FROM pg_proc p
346+
JOIN pg_namespace n ON n.oid = p.pronamespace
347+
WHERE n.nspname = 'content' AND p.proname = 'remove_tag_from_document' AND p.prokind = 'p'
348+
), false),
349+
'content.remove_tag_from_document : SECURITY DEFINER (ADR-020)'
350+
);
351+
315352
-- ============================================================
316353
-- TRIGGERS modified_at — cohérence des métadonnées temporelles
317354
--

sandbox/postgres/tests/06_rls_policies.sql

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@
3232
-- ADR-020 rev. — H1-H4 : add_tag_to_document / remove_tag_from_document
3333
-- Audit org — I1-I5 : schéma org (REVOKE legal, guard create_organization, hiérarchie)
3434
-- Perm audit — moderator 124990→124990 (+manage_tags=2048)
35+
-- Audit RLS global — J1-J3 : v_auth REVOKE, v_person no PII, content_to_tag gap note
3536
-- ==============================================================================
3637

3738
\set ON_ERROR_STOP 1
3839

3940
BEGIN;
4041

41-
SELECT plan(45);
42+
SELECT plan(48);
4243

4344

4445
-- ============================================================
@@ -678,6 +679,59 @@ DELETE FROM org.org_identity WHERE entity_id = current_setting('test.org_id')::
678679
DELETE FROM org.org_core WHERE entity_id = current_setting('test.org_id')::INT;
679680
DELETE FROM org.entity WHERE id = current_setting('test.org_id')::INT;
680681

682+
683+
-- ============================================================
684+
-- SECTION J — Audit RLS global : fixes v_auth et v_person
685+
-- ============================================================
686+
687+
-- ── J1 : identity.v_auth inaccessible à marius_user (REVOKE SELECT sur la vue)
688+
-- La vue lisait identity.auth (REVOKE'd) via BYPASSRLS postgres.
689+
-- Sans REVOKE sur la vue, password_hash était lisible à tout marius_user.
690+
SELECT set_config('marius.user_id', '1', true);
691+
SELECT set_config('marius.auth_bits', '2097151', true);
692+
SET LOCAL ROLE marius_user;
693+
694+
SELECT throws_ok(
695+
$$SELECT password_hash FROM identity.v_auth LIMIT 1$$,
696+
'42501', NULL,
697+
'REVOKE SELECT : identity.v_auth inaccessible à marius_user (password_hash protégé)'
698+
);
699+
700+
RESET ROLE;
701+
702+
703+
-- ── J2 : identity.v_person ne projette pas email ni téléphone
704+
-- Colonnes PII issues de identity.person_contact (REVOKE'd) retirées de la projection.
705+
SET LOCAL ROLE marius_user;
706+
707+
SELECT ok(
708+
NOT EXISTS (
709+
SELECT 1 FROM information_schema.columns
710+
WHERE table_schema = 'identity'
711+
AND table_name = 'v_person'
712+
AND column_name IN ('email', 'telephone', 'phone')
713+
),
714+
'Vue v_person : colonnes PII (email, telephone) absentes de la projection'
715+
);
716+
717+
RESET ROLE;
718+
719+
720+
-- ── J3 : identity.v_person conserve url (donnée de contact publique)
721+
SET LOCAL ROLE marius_user;
722+
723+
SELECT ok(
724+
EXISTS (
725+
SELECT 1 FROM information_schema.columns
726+
WHERE table_schema = 'identity'
727+
AND table_name = 'v_person'
728+
AND column_name = 'url'
729+
),
730+
'Vue v_person : url (site web public) conservé dans la projection'
731+
);
732+
733+
RESET ROLE;
734+
681735
SELECT * FROM finish();
682736
ROLLBACK;
683737

0 commit comments

Comments
 (0)