d815c7ab63
Transforme rbac_50_roles.json en fixtures Frappe/ERPNext v15 natives, prêtes à appliquer via bench (VPS · agent ERPNext). Réalise le prochain incrément annoncé au §7 de RBAC_50_ROLES_SPEC.md. Zéro pip, zéro VPS, sortie déterministe. - fixturelib/frappe.py : modèle natif (15 flags DocPerm, mapping 1:1 des verbes RBAC, if_owner⇔scope "own"). fixturelib/builder.py : bundle déterministe. - rbac_fixtures_gen.py : CLI build/validate (refuse d'écrire si invariant KO). - fixtures.schema.json : contrat de sortie (validateur maison Publiciste réutilisé). - 11 tests unittest : schéma+oracle, 50 rôles, séparation des pouvoirs, round-trip fidèle au contrat, déterminisme. Job CI rbac-fixtures-tests au gate. - Anti-invention #6 : 100% dérivé du contrat, flags non pilotés à 0, DocTypes custom signalés « à confirmer VPS ». Vérif : 11/11 verts + gate CI local vert (exit 0) + régression 60 tests OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe fixtures (Sprint 2).
|
|
|
|
Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit
|
|
JAMAIS sur le VPS — il produit en-repo les fichiers de fixtures que l'agent
|
|
appliquera côté serveur via `bench --site frontend migrate` / `import-fixtures`.
|
|
|
|
Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en :
|
|
- role.json → fixtures DocType `Role` (50 rôles préfixés « OTO »)
|
|
- custom_docperm.json → fixtures `Custom DocPerm` (permissions par DocType)
|
|
- MANIFEST.json → traçabilité + DocTypes DTP `custom` à confirmer VPS
|
|
|
|
Sous-commandes :
|
|
build [-o OUT] → écrit role.json / custom_docperm.json / MANIFEST.json
|
|
validate [-o OUT] → (re)génère en mémoire, valide vs fixtures.schema.json,
|
|
vérifie les invariants (50 rôles, unicité, séparation
|
|
des pouvoirs) — sort en erreur si un invariant casse.
|
|
|
|
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
|
|
Anti-invention (#6) : 100 % des données proviennent du contrat ; les seuls
|
|
ajouts sont des flags DocPerm natifs à 0 (défaut sûr) et des méta de traçabilité.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_RBAC_DIR = os.path.normpath(os.path.join(_HERE, "..")) # rbac/
|
|
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, "..")) # 05_deliverables_mvp/
|
|
|
|
sys.path.insert(0, _HERE)
|
|
# Réutilisation (workflow #5 : zéro duplication) du validateur maison Publiciste.
|
|
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
|
|
|
from fixturelib import builder # noqa: E402
|
|
from lib import validator as maison # type: ignore # noqa: E402
|
|
|
|
_CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json")
|
|
_SCHEMA_PATH = os.path.join(_HERE, "fixtures.schema.json")
|
|
_DEFAULT_OUT = os.path.join(_HERE, "out")
|
|
|
|
|
|
def _eprint(*args) -> None:
|
|
print(*args, file=sys.stderr)
|
|
|
|
|
|
def _load(path: str) -> dict:
|
|
with open(path, encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def _write_json(path: str, data) -> None:
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
fh.write("\n")
|
|
|
|
|
|
def _build_bundle() -> dict:
|
|
return builder.build_bundle(_load(_CONTRACT_PATH))
|
|
|
|
|
|
def _validate_bundle(bundle: dict) -> list[str]:
|
|
"""Valide le bundle contre fixtures.schema.json + invariants métier RBAC."""
|
|
schema = _load(_SCHEMA_PATH)
|
|
errors = list(maison.validate(bundle, schema))
|
|
|
|
manifest = bundle["manifest"]
|
|
roles = bundle["role"]
|
|
docperms = bundle["custom_docperm"]
|
|
|
|
# Invariant 1 · cible non-négociable « RBAC 50 rôles ».
|
|
if manifest["cible_rbac_roles"] != len(roles):
|
|
errors.append(
|
|
f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ role fixtures={len(roles)}"
|
|
)
|
|
|
|
# Invariant 2 · unicité des noms de rôle Frappe (anti-collision).
|
|
names = [r["role_name"] for r in roles]
|
|
if len(names) != len(set(names)):
|
|
errors.append("Noms de rôle Frappe dupliqués dans les fixtures Role.")
|
|
|
|
# Invariant 3 · unicité (role, DocType, permlevel) côté DocPerm.
|
|
keys = [(d["role"], d["parent"], d["permlevel"]) for d in docperms]
|
|
if len(keys) != len(set(keys)):
|
|
errors.append("DocPerm dupliqué pour un même (role, DocType, permlevel).")
|
|
|
|
# Invariant 4 · séparation des pouvoirs (#6) : `set_user_permissions`
|
|
# réservé au seul RBAC Admin (aucune élévation de privilège fixée en dur).
|
|
contract = _load(_CONTRACT_PATH)
|
|
admin_roles = {
|
|
r["erpnext_role_name"]
|
|
for r in contract["roles"]
|
|
if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe"
|
|
}
|
|
for d in docperms:
|
|
if d["set_user_permissions"] == 1 and d["role"] not in admin_roles:
|
|
errors.append(
|
|
f"Élévation de privilège : {d['role']} porte set_user_permissions "
|
|
f"sur {d['parent']} (réservé au RBAC Admin)."
|
|
)
|
|
|
|
# Invariant 5 · cohérence de comptage du manifeste.
|
|
if manifest["counts"]["role"] != len(roles):
|
|
errors.append("counts.role incohérent avec le nombre de fixtures Role.")
|
|
if manifest["counts"]["custom_docperm"] != len(docperms):
|
|
errors.append("counts.custom_docperm incohérent avec les DocPerm.")
|
|
|
|
return errors
|
|
|
|
|
|
def cmd_build(args: argparse.Namespace) -> int:
|
|
bundle = _build_bundle()
|
|
errors = _validate_bundle(bundle)
|
|
if errors:
|
|
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
|
|
out = os.path.abspath(args.out)
|
|
os.makedirs(out, exist_ok=True)
|
|
_write_json(os.path.join(out, "role.json"), bundle["role"])
|
|
_write_json(os.path.join(out, "custom_docperm.json"), bundle["custom_docperm"])
|
|
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
|
|
|
m = bundle["manifest"]
|
|
print(f"✅ Fixtures générées dans {out}")
|
|
print(f" role.json : {m['counts']['role']} rôles")
|
|
print(
|
|
f" custom_docperm.json : {m['counts']['custom_docperm']} DocPerm "
|
|
f"sur {m['counts']['doctypes_uniques']} DocTypes"
|
|
)
|
|
if m["custom_doctypes_a_confirmer"]:
|
|
print(
|
|
" ⚠ DocTypes DTP `custom` à créer + confirmer VPS avant import : "
|
|
+ ", ".join(m["custom_doctypes_a_confirmer"])
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
bundle = _build_bundle()
|
|
errors = _validate_bundle(bundle)
|
|
if errors:
|
|
_eprint("❌ Validation KO :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
m = bundle["manifest"]
|
|
print(
|
|
f"✅ Validation OK — {m['counts']['role']} rôles, "
|
|
f"{m['counts']['custom_docperm']} DocPerm, schéma + invariants verts."
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(description="Générateur de fixtures ERPNext depuis le contrat RBAC.")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pb = sub.add_parser("build", help="génère role.json / custom_docperm.json / MANIFEST.json")
|
|
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
|
|
pb.set_defaults(func=cmd_build)
|
|
|
|
pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
|
|
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
|
|
pv.set_defaults(func=cmd_validate)
|
|
|
|
args = p.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|