[DTP-Worker] Sprint 2 · Générateur plan User Permission (RBAC row-level scope_donnees → Frappe)

Complète le pipeline RBAC (Role + Custom DocPerm déjà livrés) par la dimension
row-level. Mapping natif ERPNext v15 des 4 scope_donnees :
- entite → User Permission allow=Company (28 templates, user=sentinelle)
- own    → if_owner (déjà posé par fixtures_gen)
- groupe → aucune restriction (vue consolidée)
- equipe → pas de dimension native → signalé VPS (jamais mappé, #6)

Module userperm_gen/ : permlib/{frappe,builder}, CLI build/validate (refuse
d'écrire si invariant KO), userperm.schema.json (validateur maison, zéro pip),
12 tests unittest, README. Job CI rbac-userperm-tests ajouté au gate.
Docs SPEC §7 + fixtures_gen README cousues. Régression 72 tests verts, gate OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 03:33:44 +00:00
parent d815c7ab63
commit b1b011ab81
12 changed files with 925 additions and 3 deletions
@@ -0,0 +1,7 @@
"""Générateur de plan `User Permission` ERPNext depuis le contrat RBAC 50 rôles.
Complète le générateur de fixtures `Role` + `Custom DocPerm` (`../fixtures_gen`)
en couvrant la dimension ROW-LEVEL (`scope_donnees`) que les DocPerm ne portent
pas à eux seuls. Voir `frappe.py` pour la connaissance Frappe native et
`builder.py` pour la construction déterministe.
"""
@@ -0,0 +1,82 @@
"""Construction du plan `User Permission` depuis `rbac_50_roles.json`.
Entrée : le contrat RBAC 50 rôles (déjà validé par `rbac.schema.json` dans le
job CI `rbac-tests`). Sortie : un « plan » déterministe qui, pour CHAQUE rôle,
indique le mécanisme Frappe d'enforcement row-level de son `scope_donnees` et,
le cas échéant, un template `User Permission` (allow=Company) à matérialiser par
utilisateur côté VPS.
Déterminisme : tri stable des entrées par nom de rôle Frappe → sortie
reproductible bit-à-bit à contrat constant (diffable, re-générable en CI).
Anti-invention (#6) : 100 % des données proviennent du contrat (nom du rôle,
scope, entité). Aucun utilisateur n'est inventé (sentinelle `USER_PLACEHOLDER`).
Les rôles `equipe` (pas de dimension row-level native) ne reçoivent AUCUN
template et sont signalés « à confirmer VPS » plutôt que mappés arbitrairement.
"""
from __future__ import annotations
from typing import Any
from . import frappe
def _sorted_roles(contract: dict) -> list[dict]:
return sorted(contract["roles"], key=lambda r: r["erpnext_role_name"])
def build_plan(contract: dict) -> dict[str, Any]:
"""Transforme le contrat RBAC en plan `User Permission` déterministe."""
roles = _sorted_roles(contract)
plan: list[dict] = []
companies: set[str] = set() # for_value distincts (portée entite)
equipe_roles: list[str] = [] # rôles equipe → à confirmer VPS
by_mechanism: dict[str, int] = {m: 0 for m in frappe.SCOPE_MECHANISM.values()}
for r in roles:
role_name = r["erpnext_role_name"]
scope = r["scope_donnees"]
entite = r["entite_principale"]
mechanism = frappe.mechanism_for(scope)
by_mechanism[mechanism] += 1
template = None
if mechanism == frappe.MECH_WITH_TEMPLATE:
# Lève ValueError si le contrat couple `entite` à un périmètre
# consolidé (« Groupe ») — garde-fou anti-contrat corrompu.
template = frappe.user_permission_template(entite)
companies.add(entite)
elif scope == "equipe":
equipe_roles.append(role_name)
plan.append(
{
"erpnext_role_name": role_name,
"scope_donnees": scope,
"entite_principale": entite,
"mechanism": mechanism,
"user_permission_template": template,
}
)
manifest = {
"generated_from": "rbac_50_roles.json",
"source_version": contract["version"],
"cible_rbac_roles": contract["cible_rbac_roles"],
"counts": {
"plan_entries": len(plan),
"user_permission_templates": by_mechanism[frappe.MECH_WITH_TEMPLATE],
"by_mechanism": by_mechanism,
},
# Companies ERPNext à CRÉER/confirmer sur le VPS avant d'appliquer les
# User Permission (les noms viennent de entite_principale du contrat).
"companies_a_confirmer": sorted(companies),
# Rôles `equipe` : aucune dimension row-level native → l'agent ERPNext
# doit décider du mécanisme (champ custom + User Permission, ou
# hiérarchie Employee.reports_to). Jamais mappé en douce ici (#6).
"roles_scope_equipe_a_confirmer": sorted(equipe_roles),
}
return {"manifest": manifest, "plan": plan}
@@ -0,0 +1,100 @@
"""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,
}