Skip to content

Commit a7829a8

Browse files
committed
fix: updated database
1 parent 23d49d7 commit a7829a8

7 files changed

Lines changed: 1779 additions & 31 deletions

File tree

sandbox/postgres/master_schema_ddl.pgsql

Lines changed: 346 additions & 29 deletions
Large diffs are not rendered by default.

sandbox/postgres/tests/01_schema_and_security.sql

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

1515
BEGIN;
1616

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

1919

2020
-- ============================================================
@@ -97,6 +97,43 @@ SELECT ok(
9797
);
9898

9999

100+
-- ============================================================
101+
-- TYPES PHYSIQUES — Codes ISO pass-by-value (ADR-028)
102+
--
103+
-- identity.person_identity.nationality : SMALLINT ISO 3166-1 numérique.
104+
-- Avant audit DOD : CHAR(2) alpha-2 → varlena, violait ADR-026 + ADR-028
105+
-- et laissait un gap de 2B à l'offset 6-7 (après gender SMALLINT).
106+
-- Après correction : 2×SMALLINT (gender offset 4, nationality offset 6)
107+
-- consomment exactement les 4B post-INT4, zéro gap structurel.
108+
--
109+
-- identity.account_core.language : VARCHAR(5) — pas CHAR(5).
110+
-- CHAR(5) est bpchar dans PostgreSQL : varlena avec padding/stripping CPU.
111+
-- ADR-026 : l'invariant de longueur est garanti par la DEFAULT 'fr_FR' et
112+
-- la contrainte applicative, pas par le type.
113+
-- ============================================================
114+
115+
SELECT col_type_is(
116+
'identity', 'person_identity', 'nationality',
117+
'smallint',
118+
'person_identity.nationality : smallint ISO 3166-1 numérique (ADR-028 — gap offset 6-7 absorbé)'
119+
);
120+
121+
SELECT col_type_is(
122+
'identity', 'account_core', 'language',
123+
'character varying(5)',
124+
'account_core.language : character varying(5), pas bpchar (ADR-026)'
125+
);
126+
127+
SELECT ok(
128+
EXISTS (
129+
SELECT 1 FROM information_schema.check_constraints
130+
WHERE constraint_schema = 'identity'
131+
AND constraint_name = 'nationality_range'
132+
),
133+
'person_identity.nationality_range : contrainte CHECK 1-999 présente (ADR-028)'
134+
);
135+
136+
100137
-- ============================================================
101138
-- ADR-024 — Colonnes de snapshot complètes dans content.revision
102139
--
@@ -432,5 +469,181 @@ SELECT ok(
432469
);
433470

434471

472+
-- ============================================================
473+
-- TOAST TUPLE TARGET — composants basse fréquence (Audit 1.2)
474+
--
475+
-- toast_tuple_target = 128 force le moteur à TOASTer les varlena dès 128 B
476+
-- au lieu du seuil par défaut (~2 kB). Effet : les composants "cold" (textes longs,
477+
-- descriptions, corps HTML) ne chargent jamais de données textuelles lors des
478+
-- scans de métadonnées sur les tables "hot" associées.
479+
--
480+
-- Invariant : seuls les composants explicitement déclarés basse fréquence portent
481+
-- ce paramètre. Les tables hot path (identity.auth, content.core, product_core)
482+
-- ne doivent PAS l'avoir — leur densité de tuple dépend de l'inline des varlena.
483+
-- ============================================================
484+
485+
SELECT ok(
486+
COALESCE(
487+
(SELECT reloptions @> ARRAY['toast_tuple_target=128']
488+
FROM pg_class WHERE relname = 'place_content'),
489+
false
490+
),
491+
'TOAST geo.place_content : toast_tuple_target = 128 (corps textuel isolé)'
492+
);
493+
494+
SELECT ok(
495+
COALESCE(
496+
(SELECT reloptions @> ARRAY['toast_tuple_target=128']
497+
FROM pg_class WHERE relname = 'person_content'),
498+
false
499+
),
500+
'TOAST identity.person_content : toast_tuple_target = 128 (biographie, TRÈS BASSE fréquence)'
501+
);
502+
503+
SELECT ok(
504+
COALESCE(
505+
(SELECT reloptions @> ARRAY['toast_tuple_target=128']
506+
FROM pg_class WHERE relname = 'product_content'),
507+
false
508+
),
509+
'TOAST commerce.product_content : toast_tuple_target = 128 (description catalogue)'
510+
);
511+
512+
SELECT ok(
513+
COALESCE(
514+
(SELECT reloptions @> ARRAY['toast_tuple_target=128']
515+
FROM pg_class WHERE relname = 'body'),
516+
false
517+
),
518+
'TOAST content.body : toast_tuple_target = 128 (corps HTML, BASSE fréquence)'
519+
);
520+
521+
SELECT ok(
522+
COALESCE(
523+
(SELECT reloptions @> ARRAY['toast_tuple_target=128']
524+
FROM pg_class WHERE relname = 'revision'),
525+
false
526+
),
527+
'TOAST content.revision : toast_tuple_target = 128 (snapshots cold storage)'
528+
);
529+
530+
531+
-- ============================================================
532+
-- FILLFACTOR — tables à HOT updates fréquents (Audit 2)
533+
--
534+
-- HOT update (Heap Only Tuple) : PostgreSQL réutilise l'espace libre de la
535+
-- même page pour la nouvelle version du tuple, sans créer d'entrée d'index
536+
-- supplémentaire. Condition : la nouvelle version du tuple doit tenir dans la
537+
-- même page que l'ancienne → fillfactor réserve cet espace à l'avance.
538+
--
539+
-- Sans fillfactor calibré, les HOT updates se dégradent en full updates :
540+
-- chaque mutation crée une nouvelle entrée dans tous les index de la table
541+
-- (dead tuple structurel + bloat index systématique).
542+
--
543+
-- identity.auth=70 : last_login_at mis à jour à chaque connexion (hot path ADR-008).
544+
-- 30 % de marge = ~2,4 kB libre/page → absorbe ~15 connexions avant vacuum.
545+
-- content.core=75 : status, modified_at, is_commentable mutés fréquemment.
546+
-- commerce.product_core=80 : stock décrémenté à chaque transaction.
547+
-- ============================================================
548+
549+
SELECT ok(
550+
COALESCE(
551+
(SELECT reloptions @> ARRAY['fillfactor=70']
552+
FROM pg_class WHERE relname = 'auth'),
553+
false
554+
),
555+
'fillfactor identity.auth = 70 (HOT updates last_login_at, ADR-008)'
556+
);
557+
558+
SELECT ok(
559+
-- Audit 3 : fillfactor retiré de content.core (zero HOT benefit démontré).
560+
-- Tous les chemins d'UPDATE touchent des colonnes indexées (published_at, modified_at)
561+
-- ou des conditions de partial index (status). Le fillfactor<100 dégradait la densité
562+
-- sans aucune contrepartie HOT. On vérifie l'absence du paramètre.
563+
NOT COALESCE(
564+
(SELECT reloptions @> ARRAY['fillfactor=75']
565+
FROM pg_class WHERE relname = 'core'
566+
AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'content')),
567+
false
568+
),
569+
'fillfactor content.core : paramètre absent (Audit 3 — zero HOT benefit, densité +25%)'
570+
);
571+
572+
SELECT ok(
573+
COALESCE(
574+
(SELECT reloptions @> ARRAY['fillfactor=80']
575+
FROM pg_class WHERE relname = 'product_core'),
576+
false
577+
),
578+
'fillfactor commerce.product_core = 80 (HOT updates stock, ADR-024 FOR UPDATE)'
579+
);
580+
581+
582+
-- ============================================================
583+
-- INDEX PARTIELS — couverture des hot scans (Audit 2)
584+
--
585+
-- product_core_catalog (Audit 2 — gap identifié) :
586+
-- Avant correction : aucun index sur is_available. Un listing catalogue
587+
-- déclenchait un seq scan sur product_core entier, y compris les produits
588+
-- désactivés (cold data). L'index partial filtre ce segment dès le parcours.
589+
--
590+
-- core_modified (Audit 2 — gap identifié) :
591+
-- Avant correction : seul BRIN(created_at) était présent — optimisé pour les
592+
-- scans par plage de création, pas pour ORDER BY modified_at DESC (dashboard
593+
-- éditorial "derniers articles modifiés"). L'index partial exclut les
594+
-- brouillons jamais modifiés (modified_at IS NOT NULL), réduisant sa surface.
595+
-- ============================================================
596+
597+
SELECT ok(
598+
EXISTS (
599+
SELECT 1 FROM pg_indexes
600+
WHERE schemaname = 'commerce'
601+
AND tablename = 'product_core'
602+
AND indexname = 'product_core_catalog'
603+
),
604+
'Index product_core_catalog présent : scan catalogue sans seq scan (Audit 2)'
605+
);
606+
607+
SELECT ok(
608+
EXISTS (
609+
SELECT 1 FROM pg_indexes
610+
WHERE schemaname = 'content'
611+
AND tablename = 'core'
612+
AND indexname = 'core_modified'
613+
),
614+
'Index core_modified présent : dashboard éditorial ORDER BY modified_at (Audit 2)'
615+
);
616+
617+
618+
-- ============================================================
619+
-- BRIN IMMUTABILITÉ — created_at ne doit jamais être modifié (Audit 3)
620+
--
621+
-- Un UPDATE sur created_at invalide la corrélation physique/logique de l'index
622+
-- BRIN et peut produire des faux négatifs (lignes exclues à tort lors d'un
623+
-- scan de zone). Les quatre triggers ci-dessous lèvent SQLSTATE 55000
624+
-- si OLD.created_at IS DISTINCT FROM NEW.created_at.
625+
-- ============================================================
626+
627+
SELECT has_trigger(
628+
'identity', 'auth', 'auth_deny_created_at_update',
629+
'Trigger auth_deny_created_at_update : created_at immuable sur identity.auth (Audit 3)'
630+
);
631+
632+
SELECT has_trigger(
633+
'content', 'core', 'core_deny_created_at_update',
634+
'Trigger core_deny_created_at_update : created_at immuable sur content.core (Audit 3)'
635+
);
636+
637+
SELECT has_trigger(
638+
'commerce', 'transaction_core', 'transaction_deny_created_at_update',
639+
'Trigger transaction_deny_created_at_update : created_at immuable sur commerce.transaction_core (Audit 3)'
640+
);
641+
642+
SELECT has_trigger(
643+
'org', 'org_core', 'org_core_deny_created_at_update',
644+
'Trigger org_core_deny_created_at_update : created_at immuable sur org.org_core (Audit 3)'
645+
);
646+
647+
435648
SELECT * FROM finish();
436649
ROLLBACK;

sandbox/postgres/tests/02_identity_logic.sql

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

1515
BEGIN;
1616

17-
SELECT plan(13);
17+
SELECT plan(15);
1818

1919

2020
-- ============================================================
@@ -255,5 +255,52 @@ SELECT ok(
255255
);
256256

257257

258+
-- ============================================================
259+
-- Garde d'escalade de rôle dans create_account (Audit 4)
260+
--
261+
-- Un subscriber (role_id=7, auth_bits=16384) ne doit pas pouvoir
262+
-- créer un compte avec un rôle privilégié (ex: administrator, role_id=1).
263+
-- Sans manage_users (bit 8, valeur 256), la procédure doit lever 42501.
264+
-- Le contexte système (rls_user_id=-1) bypasse la garde — les appels
265+
-- CI/CD et seed ci-dessus (role_id=1, role_id=2) restent valides.
266+
-- ============================================================
267+
268+
SELECT set_config('marius.user_id',
269+
(SELECT val::text FROM _ids WHERE key = 'acct1_id'), true);
270+
SELECT set_config('marius.auth_bits', '16384', true); -- subscriber : pas manage_users
271+
SET LOCAL ROLE marius_user;
272+
273+
SELECT throws_ok(
274+
$$CALL identity.create_account('evil_admin','$argon2id$v=19$m=65536$x','evil-admin',1,'fr_FR')$$,
275+
'42501',
276+
NULL,
277+
'create_account : subscriber ne peut pas créer un compte administrator (role_id=1) — Audit 4'
278+
);
279+
280+
RESET ROLE;
281+
282+
283+
-- ============================================================
284+
-- Garde create_person (Audit 4)
285+
--
286+
-- create_person requiert manage_users (256) hors contexte système.
287+
-- Un subscriber (16384) doit être rejeté.
288+
-- ============================================================
289+
290+
SELECT set_config('marius.user_id',
291+
(SELECT val::text FROM _ids WHERE key = 'acct1_id'), true);
292+
SELECT set_config('marius.auth_bits', '16384', true);
293+
SET LOCAL ROLE marius_user;
294+
295+
SELECT throws_ok(
296+
$$CALL identity.create_person('Jean','Dupont',NULL,NULL)$$,
297+
'42501',
298+
NULL,
299+
'create_person : subscriber rejeté sans manage_users — Audit 4'
300+
);
301+
302+
RESET ROLE;
303+
304+
258305
SELECT * FROM finish();
259306
ROLLBACK;

0 commit comments

Comments
 (0)