4545BYPASS_ROLE = "auth_bypass_rls"
4646
4747
48+ _DOLLAR_TAG = re .compile (r"\$[A-Za-z_0-9]*\$" )
49+
50+
4851def _split_sql (sql : str ) -> list [str ]:
49- """Découpe un script SQL en instructions, en respectant les blocs $$…$$."""
50- stmts , buf , i , in_dollar , tag = [], [], 0 , False , ""
51- while i < len (sql ):
52+ """Découpe un script pg_dump en instructions exécutables.
53+
54+ Petit lexer : il neutralise les `;` qui n'ont pas valeur de terminateur —
55+ ceux nichés dans les commentaires `-- …`, les chaînes `'…'` et les blocs
56+ dollar-quote `$tag$…$tag$`. Sans cela, une ligne d'en-tête pg_dump comme
57+ `-- Name: x; Type: TABLE; Schema: public; Owner: -` était éclatée en
58+ fragments (`Type: TABLE`…) envoyés tels quels à PostgreSQL, faisant échouer
59+ tout le chargement du schéma canonique.
60+ """
61+ stmts , buf , i , n = [], [], 0 , len (sql )
62+ while i < n :
5263 ch = sql [i ]
53- if not in_dollar and sql .startswith ("$$" , i ):
54- in_dollar , tag = True , "$$"
55- buf .append ("$$" ); i += 2 ; continue
56- if in_dollar and sql .startswith (tag , i ):
57- in_dollar = False
58- buf .append (tag ); i += len (tag ); continue
59- if ch == ";" and not in_dollar :
64+ # commentaire ligne : on saute jusqu'au saut de ligne inclus
65+ if ch == "-" and i + 1 < n and sql [i + 1 ] == "-" :
66+ j = sql .find ("\n " , i )
67+ i = n if j == - 1 else j
68+ continue
69+ # chaîne littérale : un ';' n'y termine rien (les '' échappés se gèrent
70+ # par bascule successive)
71+ if ch == "'" :
72+ buf .append (ch ); i += 1
73+ while i < n :
74+ buf .append (sql [i ])
75+ if sql [i ] == "'" :
76+ i += 1 ; break
77+ i += 1
78+ continue
79+ # bloc dollar-quote : $$…$$ ou $tag$…$tag$
80+ if ch == "$" :
81+ m = _DOLLAR_TAG .match (sql , i )
82+ if m :
83+ tag = m .group (0 )
84+ end = sql .find (tag , m .end ())
85+ end = n if end == - 1 else end + len (tag )
86+ buf .append (sql [i :end ]); i = end ; continue
87+ if ch == ";" :
6088 stmt = "" .join (buf ).strip ()
6189 if stmt :
6290 stmts .append (stmt )
@@ -65,13 +93,21 @@ def _split_sql(sql: str) -> list[str]:
6593 tail = "" .join (buf ).strip ()
6694 if tail :
6795 stmts .append (tail )
68- # retire le bruit pg_dump (SET …, SELECT pg_catalog.set_config…, \connect)
96+ # retire le bruit pg_dump résiduel (SET …, SELECT pg_catalog.set_config…,
97+ # \connect, COMMENT ON EXTENSION) et les instructions vidées de tout contenu
6998 out = []
7099 for s in stmts :
71- first = s .lstrip ().split ("\n " , 1 )[0 ].upper ()
72- if first .startswith (("SET " , "SELECT PG_CATALOG" , "\\ CONNECT" , "COMMENT ON EXTENSION" )):
100+ # une instruction réduite à des lignes de commentaire est sans effet
101+ body = "\n " .join (l for l in s .splitlines () if not l .lstrip ().startswith ("--" )).strip ()
102+ if not body :
103+ continue
104+ # méta-commandes psql (\restrict, \unrestrict, \connect… — PG 17+)
105+ if body .startswith ("\\ " ):
73106 continue
74- out .append (s )
107+ first = body .split ("\n " , 1 )[0 ].upper ()
108+ if first .startswith (("SET " , "SELECT PG_CATALOG" , "COMMENT ON EXTENSION" )):
109+ continue
110+ out .append (body )
75111 return out
76112
77113
@@ -92,6 +128,12 @@ def main() -> int:
92128 f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='{ role } ') "
93129 f"THEN CREATE ROLE { role } NOLOGIN; END IF; END $$;"
94130 ))
131+ # L'utilisateur de connexion (propriétaire Render, non-superuser)
132+ # doit être MEMBRE du rôle pour pouvoir `SET ROLE` dessus lors du
133+ # seed. En dev local la connexion est superuser, ce qui masquait
134+ # l'absence de cette appartenance : le seed échouait alors sur
135+ # « permission denied to set role » à la première reconstruction.
136+ c .execute (text (f"GRANT { role } TO CURRENT_USER" ))
95137 for ext in ("pgcrypto" , "pg_trgm" , "unaccent" , '"uuid-ossp"' ):
96138 c .execute (text (f"CREATE EXTENSION IF NOT EXISTS { ext } " ))
97139 print ("✓ rôles + extensions" )
@@ -111,6 +153,11 @@ def main() -> int:
111153 sql = BASELINE .read_text ()
112154 stmts = _split_sql (sql )
113155 with eng .connect ().execution_options (isolation_level = "AUTOCOMMIT" ) as c :
156+ # pg_dump crée des fonctions qui référencent des tables définies plus
157+ # loin dans le fichier ; il pose pour cela `check_function_bodies` à
158+ # false. Le filtre anti-bruit écartant les `SET`, on le repose ici,
159+ # sans quoi get_user_for_login (utilisée par /auth/login) échoue.
160+ c .execute (text ("SET check_function_bodies = false" ))
114161 for s in stmts :
115162 try :
116163 c .execute (text (s ))
@@ -154,6 +201,33 @@ def _alembic(*args) -> bool:
154201 return 1
155202 print ("✓ schéma complet vérifié" )
156203
204+ # 3b) privilèges runtime + contournement RLS du rôle de bypass ----------
205+ #
206+ # Le dump canonique est produit sans privilèges (pg_dump --no-privileges) :
207+ # les rôles applicatifs n'héritent donc d'AUCUN droit sur les tables. On
208+ # les accorde ici. Point critique : /auth/login lit `users` sous
209+ # `SET ROLE auth_bypass_rls`, AVANT de connaître le tenant — ce rôle doit
210+ # donc contourner l'isolation par tenant. L'attribut BYPASSRLS n'est pas
211+ # posable sur une base Render (propriétaire non-superuser), mais le
212+ # propriétaire des tables PEUT créer une politique permissive équivalente.
213+ # Sans ce bloc, toute base reconstruite renvoie « permission denied for
214+ # table users » puis, une fois les droits posés, un 401 systématique.
215+ with eng .connect ().execution_options (isolation_level = "AUTOCOMMIT" ) as c :
216+ c .execute (text (f"GRANT USAGE ON SCHEMA public TO { APP_ROLE } , { BYPASS_ROLE } " ))
217+ for role in (APP_ROLE , BYPASS_ROLE ):
218+ c .execute (text (f"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO { role } " ))
219+ c .execute (text (f"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO { role } " ))
220+ c .execute (text (f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO { role } " ))
221+ c .execute (text (f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO { role } " ))
222+ rls_tables = [r [0 ] for r in c .execute (text (
223+ "SELECT relname FROM pg_class "
224+ "WHERE relrowsecurity AND relnamespace = 'public'::regnamespace ORDER BY relname" ))]
225+ for t in rls_tables :
226+ c .execute (text (f'DROP POLICY IF EXISTS bypass_all ON public."{ t } "' ))
227+ c .execute (text (f'CREATE POLICY bypass_all ON public."{ t } " '
228+ f'TO { BYPASS_ROLE } USING (true) WITH CHECK (true)' ))
229+ print (f"✓ privilèges rôles + contournement RLS ({ len (rls_tables )} table(s))" )
230+
157231 # 4) seed référentiel + admin ------------------------------------------
158232 from app .core .config import settings
159233 from app .core .db import SessionLocal
@@ -162,8 +236,14 @@ def _alembic(*args) -> bool:
162236 )
163237 from app .services .auth_service import hash_password
164238
239+ # On sème en tant que propriétaire de connexion — exactement le modèle du
240+ # runtime (app/core/db.py se connecte propriétaire, fait RESET ROLE et
241+ # isole par GUC app.tenant_id). Le propriétaire contourne la RLS ENABLE
242+ # (seule `cases` est en FORCE, non touchée ici). L'ancien
243+ # `SET ROLE auth_bypass_rls` ne fonctionnait que sous une connexion
244+ # superuser locale : sur une base Render (propriétaire non-superuser) il
245+ # échouait — d'abord faute d'appartenance au rôle, puis faute de privilège.
165246 with eng .begin () as c :
166- c .execute (text (f"SET ROLE { BYPASS_ROLE } " ))
167247 tid = c .execute (text ("SELECT id FROM tenants WHERE slug='bcrg' LIMIT 1" )).scalar ()
168248 if not tid :
169249 tid = uuid .uuid4 ()
@@ -176,7 +256,6 @@ def _alembic(*args) -> bool:
176256 "INSERT INTO users (id,email,full_name,password_hash,is_active,status,tenant_id) "
177257 "VALUES (:i,:e,'Administrateur BCRG',:p,true,'ACTIVE',:t)" ),
178258 {"i" : str (uid ), "e" : admin_email , "p" : hash_password (admin_password ), "t" : str (tid )})
179- c .execute (text ("RESET ROLE" ))
180259
181260 db = SessionLocal ()
182261 try :
0 commit comments