"""Modèle Frappe/ERPNext v15 : mapping `scope_donnees` RBAC → `User Permission`. Pourquoi ce module en plus de `../fixtures_gen` ------------------------------------------------ Les `Custom DocPerm` (générés par `fixtures_gen`) répondent à la question « QUELS verbes sur QUEL DocType » — mais PAS « SUR QUELLES LIGNES ». La portée row-level du contrat RBAC (`scope_donnees ∈ {own, equipe, entite, groupe}`) est enforcée côté Frappe par deux mécanismes NATIFS distincts : - `own` → flag `if_owner` du DocPerm (déjà posé par `fixtures_gen`, ici on ne fait que le constater — aucune `User Permission` requise). - `entite` → un enregistrement `User Permission` (allow=Company) par utilisateur, restreignant l'accès aux docs de SA Company. - `groupe` → AUCUNE restriction (vue consolidée transversale) → pas de `User Permission`. - `equipe` → PAS de dimension row-level native unique dans ERPNext v15 (ni « Team » standard). On refuse d'inventer un mécanisme (contrainte #6) : ces rôles sont signalés « à confirmer VPS ». Point CLÉ (anti-invention #6) : une `User Permission` Frappe est attachée à un UTILISATEUR, pas à un Rôle. Ce worker ne connaît AUCUN utilisateur réel → il ne peut produire qu'un TEMPLATE par rôle (champ `user` = sentinelle), que l'agent ERPNext matérialisera par utilisateur assigné, côté VPS (jamais ici). Références Frappe : - DocType `User Permission` : restreint un utilisateur aux documents dont un champ Link (`allow`) vaut `for_value`. `apply_to_all_doctypes=1` applique la restriction à TOUS les DocTypes liés à `allow` (comportement natif v15). - `Company` : chaque entité juridique OTO = une Company ERPNext (CLAUDE.md §Entités). C'est le Link de restriction pour la portée `entite`. Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables. """ from __future__ import annotations from typing import Any # --------------------------------------------------------------------------- # # Sentinelle du champ `user` d'un template : ce worker n'invente aucun # utilisateur (anti-invention #6). L'agent ERPNext remplace par le vrai e-mail # de chaque utilisateur assigné au rôle, côté VPS. # --------------------------------------------------------------------------- # USER_PLACEHOLDER = "__ASSIGN_PER_USER__" # Le DocType Link cible de la restriction `entite` : la Company Frappe native. ALLOW_COMPANY = "Company" # --------------------------------------------------------------------------- # # Mécanismes d'enforcement row-level, un par valeur de scope_donnees. # `mechanism` documente COMMENT la portée est appliquée côté Frappe (traçabilité # pour l'agent ERPNext + le SPEC). Un seul émet un template `User Permission`. # --------------------------------------------------------------------------- # SCOPE_MECHANISM: dict[str, str] = { "own": "docperm_if_owner", # posé par fixtures_gen (if_owner=1) "entite": "user_permission_company", "groupe": "none_consolidated", # vue consolidée → aucune restriction "equipe": "vps_confirm_team", # pas de dimension native → à confirmer } # Le seul mécanisme qui produit un enregistrement `User Permission` template. MECH_WITH_TEMPLATE = "user_permission_company" # Entités (CLAUDE.md §Entités) qui NE sont PAS une Company restreignable : # « Groupe » = périmètre consolidé transversal (jamais une seule Company). NON_COMPANY_ENTITES: frozenset[str] = frozenset({"Groupe"}) def mechanism_for(scope: str) -> str: """Mécanisme Frappe natif d'enforcement pour un `scope_donnees` donné.""" if scope not in SCOPE_MECHANISM: raise ValueError( f"scope_donnees inconnu (hors contrat RBAC) : {scope!r}. " f"Attendu ∈ {sorted(SCOPE_MECHANISM)}." ) return SCOPE_MECHANISM[scope] def user_permission_template(entite: str) -> dict[str, Any]: """Template `User Permission` (allow=Company) pour un rôle de portée `entite`. `user` est une SENTINELLE : l'agent ERPNext clone ce template une fois par utilisateur assigné au rôle et remplace `user` par son e-mail (côté VPS). `for_value` = l'entité juridique du contrat (à confirmer comme Company VPS). `name` est volontairement omis → Frappe l'auto-nomme à l'import. """ if entite in NON_COMPANY_ENTITES: raise ValueError( f"Portée `entite` incohérente : {entite!r} est un périmètre consolidé " f"(pas une Company). Une portée consolidée doit être `groupe`." ) return { "doctype": "User Permission", "user": USER_PLACEHOLDER, "allow": ALLOW_COMPANY, "for_value": entite, "apply_to_all_doctypes": 1, "is_default": 0, "hide_descendants": 0, }