erplibre/script/odoo/migration/check_hidden_models.py

231 lines
8 KiB
Python
Raw Permalink Normal View History

[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modèles qui ont des données que PLUS PERSONNE ne peut voir.
D'où vient ce test
------------------
Après une migration 12 → 18, les documents DMS avaient « disparu ». Ils
n'avaient pas disparu : 69 fichiers et 23 Mo étaient intacts en base. Ce
qui avait changé, c'est le modèle de sécurité — OCA DMS pose une règle
GLOBALE sur une permission accordée par un `dms.access.group`, et la
conversion depuis MuK n'en avait créé aucun, faute d'équivalent à
convertir.
Rien ne pouvait le voir venir. Les comptages de lignes disaient « tout est
là », et c'était vrai. Le test de fumée ouvrait des pages publiques, et
elles répondaient. Le trou est exactement entre les deux : des données
présentes, et une règle qui les masque intégralement.
Ce qu'on cherche
----------------
Un modèle tel que : il a des lignes, il porte au moins une règle GLOBALE,
et AUCUN utilisateur interne actif n'en voit une seule. « Aucun » est le
mot important — un modèle que seul un comptable voit est normal ; un
modèle que personne ne voit est soit une refonte de sécurité ratée, soit
des données devenues inatteignables.
Pourquoi pas en SQL
-------------------
Une règle est un domaine Odoo, parfois avec `user.`, `company_ids`, ou un
champ calculé cherchable — `permission_read` de DMS en est un, et il
déclenche une sous-requête que rien dans `ir_rule` ne laisse deviner. Il
faut l'ORM pour l'évaluer, donc le shell.
Le super-utilisateur est exclu : les règles ne s'appliquent pas à lui, et
un comptage fait en son nom déclarerait saine une base muette. C'est
précisément l'erreur qui aurait laissé passer le cas DMS.
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
"""
import argparse
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from script.odoo.migration import database_cleanup # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
DEBUT = database_cleanup.START
FIN = database_cleanup.END
# Ces modèles sont invisibles PAR DESSEIN : ils portent des règles globales
# et ne s'adressent à personne en particulier. Les signaler à chaque
# migration noierait la vraie trouvaille sous le bruit, et un rapport
# qu'on apprend à ignorer ne sert plus à rien.
ATTENDUS = (
"ir.rule",
"ir.model.access",
"res.users.log",
"bus.bus",
"bus.presence",
"mail.notification",
"ir.logging",
"ir.autovacuum",
)
SCRIPT = """
import json
LIMITE = {limite}
ATTENDUS = set({attendus!r})
rapport = {{"models": [], "checked": 0, "users": []}}
try:
# Les utilisateurs INTERNES actifs, sans le super-utilisateur : les
# règles ne s'appliquent pas à lui, et compter en son nom
# déclarerait saine une base que personne ne peut lire.
membres = env["res.users"].sudo().search([
("active", "=", True),
("id", "!=", 1),
("share", "=", False),
], limit=LIMITE)
rapport["users"] = membres.mapped("login")
# Les témoins ORDINAIRES : ceux qui ne sont pas administrateurs.
# Un modèle que seuls les administrateurs peuvent lire passe au vert
# si tous les témoins en sont — c'est exactement la forme du bug DMS,
# vue d'un autre angle. Mesuré sur une base réelle : les QUATRE
# utilisateurs internes étaient membres de base.group_system.
rapport["ordinary"] = [
u.login for u in membres if not u.has_group("base.group_system")
]
[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
if not membres:
rapport["no_user"] = True
else:
vus = set(
env["ir.rule"].sudo()
.search([("global", "=", True), ("active", "=", True)])
.mapped("model_id.model")
)
# La COUVERTURE : combien de modèles portent une règle globale,
# et non combien on a pu éprouver. « 45 éprouvés » sans dire
# « sur 125 » laisse croire à un examen complet.
rapport["with_rule"] = len(vus - ATTENDUS)
[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
for nom in sorted(vus - ATTENDUS):
modele = env.get(nom)
if modele is None or modele._abstract or modele._transient:
continue
try:
total = modele.sudo().search_count([])
except Exception:
continue
if not total:
continue
rapport["checked"] += 1
# Court-circuit : dès qu'UN utilisateur voit une ligne, le
# modèle n'est pas muet. Inutile d'interroger les autres.
visible = False
for membre in membres:
try:
if modele.with_user(membre).search([], limit=1):
visible = True
break
except Exception:
# Un refus d'accès n'est pas une ligne visible.
continue
if not visible:
rapport["models"].append({{"model": nom, "rows": total}})
except Exception as exc:
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
"""
def build_script(limite=25):
return SCRIPT.format(
limite=limite, attendus=list(ATTENDUS), debut=DEBUT, fin=FIN
)
def render(rapport):
if rapport.get("no_user"):
return [f"⚠ {t('No internal user to test visibility with.')}"]
muets = rapport.get("models") or []
porteurs = rapport.get("with_rule")
sur = f" {t('out of')} {porteurs}" if porteurs else ""
[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
lignes = [
f"🔍 {rapport.get('checked', 0)}{sur}"
[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
f" {t('model(s) with a global rule and some data,')}"
f" {t('checked against')} {len(rapport.get('users') or [])}"
f" {t('internal user(s)')}"
]
# Dire ce qu'on n'a PAS pu éprouver, avant de dire ce qu'on a trouvé.
# Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge.
if "ordinary" in rapport and not rapport["ordinary"]:
lignes.append(
f" ⚠ {t('Every witness is an administrator: a model only')}"
f" {t('admins can read passes this test.')}"
)
lignes.append(
f" ℹ {t('Reads ir_rule only — masking coded in Python escapes it.')}"
)
[ADD] migration: repair DMS at the 13 bump, and catch the whole class The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5
2026-08-21 05:25:23 -04:00
if not muets:
lignes.append(f" ✅ {t('Every one of them is visible to someone.')}")
return lignes
lignes.append(
f" ❌ {len(muets)} {t('model(s) nobody can see a single row of')} :"
)
for entree in sorted(muets, key=lambda item: -item["rows"]):
lignes.append(
f" {entree['model']:<38} {entree['rows']:>8}"
f" {t('row(s)')}"
)
lignes.append(
f" {t('The data is there; a global rule hides all of it.')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report models that hold data no internal user can see,"
" because a global record rule filters every row out."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--users",
type=int,
default=25,
help="how many internal users to test with (default: 25)",
)
config = parser.parse_args(argv)
souci = database_cleanup.require_matching_version(config.database)
if souci:
print(f"❌ {souci}")
return 2
try:
rapport = database_cleanup.run_shell(
config.database,
config.config,
build_script(config.users),
echo=lambda texte: print(f"⧖ {texte}", flush=True),
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if rapport.get("error"):
print(f"❌ {rapport['error']}")
return 2
print("\n".join(render(rapport)))
return 1 if rapport.get("models") else 0
if __name__ == "__main__":
sys.exit(main())