[ADD] migration: restore DMS visibility lost with MuK's access model

Nothing was lost at the 13 bump: 69 files, 16 folders and 23 MB of
content_binary are identical from 12 to 18. What changed is the security
model. MuK filtered on company alone; OCA DMS adds a GLOBAL rule on
permission_read, granted only through a dms.access.group or through
storages saved as attachment. The migration created no group — MuK had
none to convert — and the storages are database. Both doors shut, every
user sees zero.

Reports without writing unless --apply. The visibility count uses a real
user: the global rule spares the superuser, so a sudo count would call a
mute database healthy.

--- FR ---

Rien n'a été perdu au palier 13 : 69 fichiers, 16 dossiers et 23 Mo de
content_binary sont identiques de la 12 à la 18. C'est le modèle de
sécurité qui a changé. MuK ne filtrait que sur la société ; OCA DMS
ajoute une règle GLOBALE sur permission_read, accordée par une
dms.access.group ou par un stockage en attachment. La migration n'a créé
aucun groupe — MuK n'en avait aucun à convertir — et les stockages sont
en database. Les deux portes fermées, personne ne voit rien.

Rapport sans écriture sauf --apply. Le comptage de visibilité passe par
un vrai utilisateur : la règle globale épargne le super-utilisateur, donc
un comptage en sudo déclarerait saine une base muette.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-21 05:00:38 -04:00
parent d2f70f6535
commit a9d9a08574
3 changed files with 542 additions and 0 deletions

View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Rendre visibles les documents DMS qu'une migration a rendus muets.
Le symptôme : après le palier 13, les documents DMS « ont disparu ». Ils
n'ont pas disparu. Mesuré sur une base migrée : 69 fichiers, 16 dossiers,
23 Mo de `content_binary`, présents à l'identique de la 12 à la 18. Rien
n'a été perdu.
Ce qui a changé, c'est le MODÈLE DE SÉCURITÉ. En 12, MuK DMS ne filtrait
que sur la société :
['|', ('company','=',False), ('company','child_of',[...])]
OCA DMS, à partir de la 13, ajoute des règles GLOBALES sur une permission
calculée :
[('permission_read', '=', user.id)]
`_search_permission_read` n'accorde l'accès que par deux portes :
1. Une `dms.access.group` dont l'utilisateur est membre, reliée au
dossier — la migration n'en a créé AUCUNE, parce que MuK n'en avait
aucune à convertir.
2. L'héritage depuis l'enregistrement lié, qui exige
`storage.save_type = 'attachment'` — or les stockages migrés sont en
`database`.
Les deux portes fermées, la règle globale filtre TOUT. Pas un dossier,
pas un fichier, pour personne — l'administrateur compris, car `admin`
n'est pas le super-utilisateur au sens d'Odoo.
Ce que fait la réparation
-------------------------
Une seule `dms.access.group`, adossée au groupe Odoo `dms.group_dms_user`
plutôt qu'à une liste d'utilisateurs — ainsi l'appartenance continue de
suivre le groupe, comme avant la migration —, reliée aux dossiers
RACINES. Les enfants héritent : ils portent tous
`inherit_group_ids = true`.
Ce qu'elle ne fait pas : inventer une granularité que MuK n'avait pas.
L'ancien modèle ne connaissait pas d'ACL par dossier ; en fabriquer une
ici serait décider à la place de l'utilisateur, sur des données qu'il
n'a jamais saisies.
Codes de sortie : 0 rien à faire, 1 il y a à faire (ou c'est fait), 2 échec.
"""
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
# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et
# elles ne servent qu'à isoler le rapport des journaux d'Odoo. En
# inventer une seconde paire aurait créé un contrat parallèle à tenir.
DEBUT = database_cleanup.START
FIN = database_cleanup.END
NOM_GROUPE = "Migration MuK → DMS"
# Le script poussé dans `odoo-bin shell`. Il compte AVANT et APRÈS avec un
# vrai utilisateur : c'est la seule preuve qui vaille — la règle globale
# ne s'applique pas au super-utilisateur, donc un compte fait en sudo
# dirait « tout va bien » alors que personne ne voit rien.
SCRIPT = """
import json
DRY = {dry}
NOM = {nom!r}
rapport = {{"dry_run": DRY}}
try:
Dossier = env["dms.directory"].sudo()
Fichier = env["dms.file"].sudo()
Groupe = env["dms.access.group"].sudo()
rapport["files"] = Fichier.search_count([])
rapport["directories"] = Dossier.search_count([])
rapport["access_groups_before"] = Groupe.search_count([])
# Un vrai utilisateur, pas le super-utilisateur : la règle globale ne
# s'applique qu'à lui, et c'est justement ce qu'on mesure.
membres = env.ref("dms.group_dms_user").users.filtered(
lambda u: u.id != 1 and u.active
)
temoin = membres[:1]
rapport["witness"] = temoin.login if temoin else None
def visible():
if not temoin:
return None
return {{
"directories": Dossier.with_user(temoin).search_count([]),
"files": Fichier.with_user(temoin).search_count([]),
}}
rapport["before"] = visible()
racines = Dossier.search([("is_root_directory", "=", True)])
rapport["roots"] = racines.mapped("name")
deja = Groupe.search([("name", "=", NOM)])
rapport["already_repaired"] = bool(deja)
if not DRY and not deja and racines:
Groupe.create({{
"name": NOM,
"perm_create": True,
"perm_write": True,
"perm_unlink": True,
"group_ids": [(6, 0, [env.ref("dms.group_dms_user").id])],
"directory_ids": [(6, 0, racines.ids)],
}})
env.cr.commit()
env.registry.clear_cache()
rapport["created"] = True
rapport["after"] = visible()
rapport["access_groups_after"] = Groupe.search_count([])
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(dry_run):
return SCRIPT.format(
dry="True" if dry_run else "False",
nom=NOM_GROUPE,
debut=DEBUT,
fin=FIN,
)
def render(rapport, dry_run):
lignes = [
f"📁 {t('DMS documents in the database')} :"
f" {rapport.get('files', '?')} {t('file(s)')},"
f" {rapport.get('directories', '?')} {t('folder(s)')}"
]
avant = rapport.get("before")
temoin = rapport.get("witness")
if avant is None:
lignes.append(f" ⚠ {t('No DMS user to test visibility with.')}")
else:
lignes.append(
f" {t('Visible to')} {temoin} :"
f" {avant['directories']} {t('folder(s)')},"
f" {avant['files']} {t('file(s)')}"
)
lignes.append(
f" {t('DMS access groups:')} {rapport.get('access_groups_before', '?')}"
)
if rapport.get("already_repaired"):
lignes.append(f" ℹ️ {t('Already repaired: the group exists.')}")
return lignes
if dry_run:
lignes.append(
f" → {t('Would create one access group over')}"
f" {len(rapport.get('roots') or [])} {t('root folder(s)')} :"
f" {', '.join(rapport.get('roots') or [])}"
)
lignes.append(f" {t('Nothing written. Re-run with --apply.')}")
return lignes
apres = rapport.get("after")
if apres:
lignes.append(
f" ✅ {t('Now visible to')} {temoin} :"
f" {apres['directories']} {t('folder(s)')},"
f" {apres['files']} {t('file(s)')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Restore visibility of DMS documents after a MuK DMS to OCA DMS"
" migration. Reports without writing unless --apply is given."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--apply",
action="store_true",
help="actually create the access group (default: report only)",
)
config = parser.parse_args(argv)
# Le même garde-fou que partout ailleurs : un Odoo d'une autre version
# ÉCRIT dans la base avant d'échouer.
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(not config.apply),
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, not config.apply)))
avant = rapport.get("before") or {}
return 1 if not avant.get("files") else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -5791,6 +5791,142 @@ TRANSLATIONS = {
"fr": "table(s) expliquées par un changement d'Odoo",
"en": "table(s) explained by an Odoo change",
},
"Cannot read the database: ": {
"fr": "Lecture impossible de la base : ",
"en": "Cannot read the database: ",
},
"Cannot read the package file: ": {
"fr": "Lecture impossible du fichier de packages : ",
"en": "Cannot read the package file: ",
},
"Modules missing from the default package": {
"fr": "📦 Modules absents du package par défaut",
"en": "📦 Modules missing from the default package",
},
"Show every entry": {
"fr": "Tout afficher",
"en": "Show every entry",
},
"List the known packages": {
"fr": "Lister les packages connus",
"en": "List the known packages",
},
"Already repaired: the group exists.": {
"fr": "Déjà réparé : le groupe existe.",
"en": "Already repaired: the group exists.",
},
"DMS access groups:": {
"fr": "Groupes d'accès DMS :",
"en": "DMS access groups:",
},
"DMS documents in the database": {
"fr": "Documents DMS dans la base",
"en": "DMS documents in the database",
},
"No DMS user to test visibility with.": {
"fr": "Aucun utilisateur DMS pour éprouver la visibilité.",
"en": "No DMS user to test visibility with.",
},
"Nothing written. Re-run with --apply.": {
"fr": "Rien d'écrit. Relancer avec --apply.",
"en": "Nothing written. Re-run with --apply.",
},
"Now visible to": {
"fr": "Désormais visibles pour",
"en": "Now visible to",
},
"Visible to": {
"fr": "Visibles pour",
"en": "Visible to",
},
"Would create one access group over": {
"fr": "Créerait un groupe d'accès sur",
"en": "Would create one access group over",
},
"file(s)": {
"fr": "fichier(s)",
"en": "file(s)",
},
"folder(s)": {
"fr": "dossier(s)",
"en": "folder(s)",
},
"root folder(s)": {
"fr": "dossier(s) racine",
"en": "root folder(s)",
},
"Census": {
"fr": "Recensement",
"en": "Census",
},
"Compared against nothing — census only.": {
"fr": "Comparé à rien — recensement seul.",
"en": "Compared against nothing — census only.",
},
"Default package:": {
"fr": "Package par défaut :",
"en": "Default package:",
},
"Every suggested module is installed.": {
"fr": "Tous les modules suggérés sont installés.",
"en": "Every suggested module is installed.",
},
"Installed modules by author": {
"fr": "Modules installés, par auteur",
"en": "Installed modules by author",
},
"Modules of": {
"fr": "Modules de",
"en": "Modules of",
},
"No default package known for this version": {
"fr": "Aucun package par défaut connu pour cette version",
"en": "No default package known for this version",
},
"Odoo": {
"fr": "Odoo",
"en": "Odoo",
},
"absent from the addons path — sync the repo first": {
"fr": "hors du chemin des addons — synchroniser le dépôt d'abord",
"en": "absent from the addons path — sync the repo first",
},
"also needs": {
"fr": "réclame aussi",
"en": "also needs",
},
"half-way — finish the pending update": {
"fr": "à mi-chemin — finir la mise à jour en attente",
"en": "half-way — finish the pending update",
},
"installed": {
"fr": "déjà en place",
"en": "installed",
},
"known": {
"fr": "connus",
"en": "known",
},
"known to the database — install it": {
"fr": "dans la base — à installer",
"en": "known to the database — install it",
},
"present but broken — fix the dependency": {
"fr": "en panne de dépendance — à réparer",
"en": "present but broken — fix the dependency",
},
"suggested module(s)": {
"fr": "module(s) suggéré(s)",
"en": "suggested module(s)",
},
"suggested module(s) not installed": {
"fr": "module(s) suggéré(s) non installé(s)",
"en": "suggested module(s) not installed",
},
"unknown author": {
"fr": "auteur inconnu",
"en": "unknown author",
},
"rows dropped, the table remains": {
"fr": "lignes supprimées, la table demeure",
"en": "rows dropped, the table remains",

View file

@ -0,0 +1,178 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qui doit tenir avant de toucher à une base de production.
Deux propriétés portent tout le reste. La première : sans `--apply`,
RIEN ne s'écrit — un outil de diagnostic qui répare tout seul est un
piège. La seconde : le compte de visibilité doit être fait avec un vrai
utilisateur, jamais en sudo, car la règle globale d'OCA DMS ne s'applique
pas au super-utilisateur et un compte en sudo dirait « tout va bien »
alors que personne ne voit rien.
"""
import ast
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import dms_access_repair as repair # noqa: E402
from script.todo import todo_i18n # noqa: E402
class TestTheGeneratedScript(unittest.TestCase):
def test_the_dry_run_never_creates_anything(self):
# Le garde vit dans le script POUSSÉ : c'est lui qui décide, pas
# l'appelant. Un `DRY = True` qui n'entoure pas le `create` ne
# protège de rien.
code = repair.build_script(dry_run=True)
self.assertIn("DRY = True", code)
arbre = ast.parse(code)
creations = [
n
for n in ast.walk(arbre)
if isinstance(n, ast.Call)
and isinstance(n.func, ast.Attribute)
and n.func.attr == "create"
]
self.assertTrue(creations, "aucune création dans le script ?")
for noeud in creations:
self.assertTrue(
self._under_dry_guard(arbre, noeud),
"une création hors du garde `not DRY`",
)
@staticmethod
def _under_dry_guard(arbre, cible):
"""La création est-elle sous un `if not DRY ...` ?"""
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.If):
continue
texte = ast.dump(noeud.test)
if "DRY" not in texte:
continue
for enfant in ast.walk(noeud):
if enfant is cible:
return True
return False
def test_apply_lifts_the_guard(self):
self.assertIn("DRY = False", repair.build_script(dry_run=False))
def test_the_script_is_valid_python(self):
for dry in (True, False):
ast.parse(repair.build_script(dry_run=dry))
def test_visibility_is_measured_with_a_real_user(self):
# `with_user` est le point tout entier : en sudo, la règle globale
# ne s'applique pas et le rapport serait rassurant à tort.
code = repair.build_script(dry_run=True)
self.assertIn("with_user(temoin)", code)
self.assertIn("u.id != 1", code)
def test_it_reuses_the_shared_sentinels(self):
from script.odoo.migration import database_cleanup
self.assertEqual(repair.DEBUT, database_cleanup.START)
self.assertEqual(repair.FIN, database_cleanup.END)
class TestTheReport(unittest.TestCase):
PLEIN = {
"files": 69,
"directories": 16,
"access_groups_before": 0,
"witness": "marie@example.org",
"before": {"directories": 0, "files": 0},
"roots": ["CNESST", "Contrat"],
"already_repaired": False,
}
def test_it_shows_what_exists_and_what_is_seen(self):
texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
self.assertIn("69", texte)
self.assertIn("marie@example.org", texte)
def test_a_dry_run_says_nothing_was_written(self):
texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
self.assertIn(
todo_i18n.t("Nothing written. Re-run with --apply."), texte
)
def test_an_applied_run_reports_the_new_visibility(self):
rapport = dict(self.PLEIN, after={"directories": 16, "files": 69})
texte = "\n".join(repair.render(rapport, dry_run=False))
self.assertIn(todo_i18n.t("Now visible to"), texte)
def test_an_already_repaired_database_says_so_and_stops(self):
rapport = dict(self.PLEIN, already_repaired=True)
texte = "\n".join(repair.render(rapport, dry_run=True))
self.assertIn(
todo_i18n.t("Already repaired: the group exists."), texte
)
self.assertNotIn(
todo_i18n.t("Would create one access group over"), texte
)
def test_no_witness_is_flagged_not_silently_fine(self):
# Sans utilisateur témoin on ne SAIT pas : le taire ferait passer
# une base muette pour une base saine.
rapport = dict(self.PLEIN, before=None, witness=None)
texte = "\n".join(repair.render(rapport, dry_run=True))
self.assertIn(
todo_i18n.t("No DMS user to test visibility with."), texte
)
def test_every_translation_key_exists(self):
with io.open(
repair.__file__.replace(".pyc", ".py"), encoding="utf-8"
) as handle:
src = handle.read()
for node in ast.walk(ast.parse(src)):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "t"
and node.args
and isinstance(node.args[0], ast.Constant)
):
self.assertIn(node.args[0].value, todo_i18n.TRANSLATIONS)
class TestTheVersionGuard(unittest.TestCase):
def setUp(self):
from script.odoo.migration import database_cleanup
self.cleanup = database_cleanup
self.vraie = database_cleanup.require_matching_version
self.vrai_shell = database_cleanup.run_shell
self.appels = []
database_cleanup.run_shell = (
lambda *a, **k: self.appels.append(a) or {}
)
def tearDown(self):
self.cleanup.require_matching_version = self.vraie
self.cleanup.run_shell = self.vrai_shell
def test_a_mismatch_stops_before_opening_the_database(self):
# Un Odoo d'une autre version ÉCRIT avant d'échouer : le refus
# doit précéder toute ouverture, pas la suivre.
self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
tampon = io.StringIO()
with redirect_stdout(tampon):
code = repair.main(["-d", "vieille_base"])
self.assertEqual(code, 2)
self.assertEqual(self.appels, [])
self.assertIn("18.0 vs 12.0", tampon.getvalue())
if __name__ == "__main__":
unittest.main()