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>
127 lines
4.2 KiB
Python
127 lines
4.2 KiB
Python
"""Modèle Frappe/ERPNext v15 : mapping RBAC → fixtures `Role` + `Custom DocPerm`.
|
|
|
|
Sépare la CONNAISSANCE FRAPPE (noms de champs, ordre des colonnes de permission,
|
|
valeurs par défaut) de la logique de construction (`builder.py`). Aucun chiffre
|
|
métier ici — uniquement la structure native ERPNext (contrainte #1 « ERPNext
|
|
natif = priorité absolue » : on n'invente aucun modèle de permission externe).
|
|
|
|
Références Frappe :
|
|
- DocType `Role` → un rôle desk (préfixe « OTO » déjà porté par le
|
|
contrat `rbac_50_roles.json`, anti-collision natif).
|
|
- DocType `Custom DocPerm`→ personnalise les permissions d'un DocType existant
|
|
SANS modifier le DocType lui-même (idéal pour appliquer par fixtures +
|
|
`bench migrate`, réversible, ne casse pas les mises à jour d'app).
|
|
|
|
Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Colonnes de permission d'un DocPerm Frappe, dans l'ordre natif du DocType.
|
|
# Toutes défaut 0 ; on ne lève à 1 que les verbes déclarés dans le contrat RBAC.
|
|
# --------------------------------------------------------------------------- #
|
|
DOCPERM_FLAGS: tuple[str, ...] = (
|
|
"select",
|
|
"read",
|
|
"write",
|
|
"create",
|
|
"delete",
|
|
"submit",
|
|
"cancel",
|
|
"amend",
|
|
"report",
|
|
"export",
|
|
"import",
|
|
"print",
|
|
"email",
|
|
"share",
|
|
"set_user_permissions",
|
|
)
|
|
|
|
# Verbes que le contrat RBAC (`permissions_cibles[].actions`) peut émettre.
|
|
# TOUS correspondent 1:1 à un flag DocPerm natif ci-dessus → aucune traduction
|
|
# arbitraire, aucune invention. Un verbe hors de cet ensemble = erreur de contrat.
|
|
RBAC_ACTIONS: frozenset[str] = frozenset(
|
|
{
|
|
"read",
|
|
"write",
|
|
"create",
|
|
"delete",
|
|
"submit",
|
|
"cancel",
|
|
"report",
|
|
"export",
|
|
"print",
|
|
"email",
|
|
"share",
|
|
"set_user_permissions",
|
|
}
|
|
)
|
|
|
|
# Verbes DocPerm jamais pilotés par le contrat RBAC (restent à 0) — documentés
|
|
# pour prouver qu'aucun flag n'est activé « en douce ».
|
|
NON_PILOTES: frozenset[str] = frozenset(set(DOCPERM_FLAGS) - RBAC_ACTIONS)
|
|
|
|
# permlevel par défaut (0 = champs de base). Le contrat RBAC ne segmente pas par
|
|
# niveau de champ → on reste au niveau 0 (pas d'invention de permlevel).
|
|
DEFAULT_PERMLEVEL = 0
|
|
|
|
# `scope_donnees == "own"` → restriction Frappe native `if_owner` au niveau
|
|
# DocPerm. Les autres portées (equipe/entite/groupe) relèvent des `User
|
|
# Permission` par utilisateur (hors périmètre worker · appliqué VPS §7 du SPEC).
|
|
SCOPE_IF_OWNER = "own"
|
|
|
|
|
|
def role_fixture(erpnext_role_name: str) -> dict[str, Any]:
|
|
"""Construit un enregistrement de fixture `Role` Frappe minimal mais valide.
|
|
|
|
`bench import-fixtures` complète les autres champs par leurs défauts DocType.
|
|
On force `desk_access=1` (rôles back-office ERPNext) et `disabled=0`.
|
|
"""
|
|
return {
|
|
"doctype": "Role",
|
|
"name": erpnext_role_name,
|
|
"role_name": erpnext_role_name,
|
|
"desk_access": 1,
|
|
"disabled": 0,
|
|
"two_factor_auth": 0,
|
|
"restrict_to_domain": None,
|
|
}
|
|
|
|
|
|
def docperm_fixture(
|
|
doctype: str,
|
|
role_name: str,
|
|
actions: list[str],
|
|
*,
|
|
if_owner: bool,
|
|
permlevel: int = DEFAULT_PERMLEVEL,
|
|
) -> dict[str, Any]:
|
|
"""Construit un enregistrement `Custom DocPerm` pour (DocType, Role).
|
|
|
|
Les flags absents de `actions` restent à 0 (défaut sûr). `name` est
|
|
volontairement omis → Frappe l'auto-nomme à l'import (hash déterministe côté
|
|
serveur), ce qui évite d'inventer un identifiant ici.
|
|
"""
|
|
unknown = set(actions) - RBAC_ACTIONS
|
|
if unknown:
|
|
raise ValueError(
|
|
f"Verbe(s) RBAC inconnu(s) pour {doctype}/{role_name} : {sorted(unknown)}"
|
|
)
|
|
rec: dict[str, Any] = {
|
|
"doctype": "Custom DocPerm",
|
|
"parent": doctype,
|
|
"parenttype": "DocType",
|
|
"parentfield": "permissions",
|
|
"role": role_name,
|
|
"permlevel": permlevel,
|
|
"if_owner": 1 if if_owner else 0,
|
|
}
|
|
wanted = set(actions)
|
|
for flag in DOCPERM_FLAGS:
|
|
rec[flag] = 1 if flag in wanted else 0
|
|
return rec
|