Skip to content

Commit f7754f3

Browse files
authored
Merge pull request #1 from THIOYE97/fix/amorcage-base-neuve
Amorçage base neuve : réparer le chargement du schéma et les rôles
2 parents 5a80ed2 + fc4b86e commit f7754f3

1 file changed

Lines changed: 95 additions & 16 deletions

File tree

scripts/init_db.py

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,46 @@
4545
BYPASS_ROLE = "auth_bypass_rls"
4646

4747

48+
_DOLLAR_TAG = re.compile(r"\$[A-Za-z_0-9]*\$")
49+
50+
4851
def _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

Comments
 (0)