diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1cea576..207fcd5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -107,10 +107,28 @@ jobs: - name: Tests générateur de fixtures RBAC run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur de plan User Permission (Sprint 2 · ERPNext Backend) : le contrat + # rbac_50_roles.json → plan row-level (scope_donnees → mécanisme Frappe natif + + # template User Permission allow=Company pour la portée `entite`). Valide le + # plan contre userperm.schema.json + invariants (couverture bijective des 50 + # rôles, template SSI `entite`, anti-invention utilisateur). stdlib pur. + # -------------------------------------------------------------------------- + rbac-userperm-tests: + name: RBAC · plan User Permission (row-level) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/rbac/userperm_gen + steps: + - uses: actions/checkout@v4 + - name: Tests générateur de plan User Permission + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session7.md b/05_deliverables_mvp/daily_reports/2026-07-30-session7.md new file mode 100644 index 0000000..c14a3ff --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session7.md @@ -0,0 +1,91 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 7) + +**Session** : `20260730_032644` + +## Tâche exécutée +**Sprint 2 · Livrable ERPNext Backend « Générateur de plan `User Permission` +row-level (RBAC 50 rôles → scope_donnees → Frappe) »** +(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » · +`RBAC_50_ROLES_SPEC.md` §7 ét. 4 · **prochaine tâche suggérée** par le daily +report session 6). + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily report session 6, + `rbac_50_roles.json` + `rbac.schema.json`, générateur de fixtures `fixtures_gen` + (Role + Custom DocPerm), validateur maison Publiciste, patron CI. +- État Sprint 2 : contrat RBAC (s4), Publiciste (s3), Faisabilité (s5), fixtures + `Role`+`Custom DocPerm` (s6) livrés. Frontend/CRM dépendent de layouts LIVE (VPS). +- **Priorité évidente** (SPEC §7 ét. 4 + report s6) : le mapping row-level + `scope_donnees → User Permission` — dernier maillon RBAC manquant en-repo, sur + le chemin critique de l'application RBAC (S4), 100 % autorable sans VPS/pip/API, + même patron que le générateur de fixtures. + +## Réalisé — module `05_deliverables_mvp/rbac/userperm_gen/` +- `permlib/frappe.py` — modèle **Frappe/ERPNext v15 natif** : mapping des 4 + valeurs `scope_donnees` → mécanisme d'enforcement row-level natif + (`own`→`if_owner` déjà posé par fixtures_gen · `entite`→`User Permission` + allow=`Company` · `groupe`→aucune restriction consolidée · `equipe`→**pas de + dimension native** → signalé). Template `User Permission` avec `user` = + **sentinelle** `__ASSIGN_PER_USER__` (une UP est per-utilisateur, pas per-rôle + → aucun utilisateur inventé, #6). Garde-fou : `entite` + périmètre consolidé + (« Groupe ») → `ValueError`. +- `permlib/builder.py` — `build_plan()` **déterministe** (tri stable par nom de + rôle) : 1 entrée/rôle + manifeste (comptes par mécanisme, Companies + + rôles `equipe` à confirmer VPS). +- `userperm_gen.py` — CLI `build` / `validate`. **Refuse d'écrire** si un + invariant casse (schéma, couverture bijective des 50 rôles, mécanisme = + mapping du scope, template SSI `entite`, fidélité `for_value`↔entité, + sentinelle utilisateur, cohérence des comptes). +- `userperm.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté par + le **validateur maison Publiciste**, zéro pip · `user_permission_template` + nullable). +- `tests/test_userperm_gen.py` — **12 tests `unittest` (stdlib pur)** : schéma + (maison + oracle `jsonschema` si présent), couverture bijective, mécanisme↔scope, + template SSI `entite`, anti-invention utilisateur, manifeste fidèle, + déterminisme, garde-fous (scope inconnu + entité consolidée). +- **CI** : job `rbac-userperm-tests` ajouté au **gate** de `.gitea/workflows/ci.yml` + (Gitea Actions uniquement · #2). +- `README.md` + `.gitignore` (output `out/` non commité, re-généré à la demande). +- **Docs cousues** : `RBAC_50_ROLES_SPEC.md §7` (ét. 4 + encart livré) et + `fixtures_gen/README.md` (ét. 4) pointent désormais vers le nouveau générateur. + +## Anti-invention (#6) appliqué +- 100 % des données (rôle, scope, entité) proviennent du contrat `rbac_50_roles.json`. +- **Aucun utilisateur inventé** : champ `user` = sentinelle (vérifié test + CLI). +- **Aucune restriction fabriquée** hors portée `entite` : `own`/`groupe`/`equipe` + → `user_permission_template = null`. +- Portée `equipe` (pas de dimension row-level native ERPNext v15) : **signalée** + dans le manifeste (options natives documentées pour l'agent ERPNext), jamais + mappée arbitrairement. +- `for_value` (Company) = `entite_principale` du contrat, à **confirmer VPS** + (jamais supposée existante). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **12/12 tests verts** (schéma maison + oracle `jsonschema`). Génération réelle : + **50 entrées**, **28 templates Company** (`entite`), 16 consolidés (`groupe`), + 2 `own`, 4 `equipe` signalés. Companies : AC Arias Cuevas, Consortium ECR DR, + Helios RD, Ploutos, WA SRL. +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`, + `check_docs.sh` (0 lien cassé), YAML `ci.yml` valide. +- **Régression** : 23 Publiciste + 10 RBAC + 11 fixtures + 16 Faisabilité + 12 + userperm = **72 tests verts** (réutilisation du validateur sans effet de bord). + +## Non fait (hors périmètre worker · VPS) +- Matérialisation des `User Permission` **par utilisateur** (remplacement de la + sentinelle) + création des Companies + import → agent ERPNext Backend. +- Décision du mécanisme des rôles `equipe` (champ custom + UP, ou + `Employee.reports_to`) → à valider avec Michel côté VPS. + +## Prochaine tâche suggérée +- ERPNext S2 : agrégateur `MANIFEST` unifié Role + DocPerm + User Permission + (vue unique d'application VPS), ou générateur `Role Profile` par portail. +- Ou Faisabilité S3 : `40_llm_outputs/` + rapports bancables FR/EN/ES. +- Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker. + +--- + +**Auto-score 4Big du livrable Générateur de plan User Permission : 96/100.** +Réserve −4 : matérialisation par utilisateur + création des Companies + décision +du mécanisme `equipe` = côté VPS (agent ERPNext, hors périmètre) ; portée `equipe` +sans dimension native délibérément non inventée (#6). Validé statiquement en-repo +(12 tests verts + round-trip fidèle au contrat + gate CI vert). diff --git a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md index ccd5be4..2763176 100644 --- a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md +++ b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md @@ -130,13 +130,24 @@ cible côté serveur (`erpnext-backend-1`) : (listés dans `MANIFEST.custom_doctypes_a_confirmer`). 3. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO (`hooks.py`), puis `bench --site frontend migrate`. -4. Mapper les `scope_donnees` en `User Permission` par utilisateur. +4. Mapper les `scope_donnees` en `User Permission` par utilisateur — **plan + livré** : [`userperm_gen/`](userperm_gen/README.md) + (`python3 userperm_gen.py build`). Matérialiser le `user_permission_template` + par utilisateur assigné (champ `user` = sentinelle) après confirmation des + Companies (`MANIFEST.companies_a_confirmer`) et décision du mécanisme des + rôles `equipe` (`MANIFEST.roles_scope_equipe_a_confirmer`). 5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big. > ✅ Le **générateur de fixtures** `rbac_50_roles.json → fixtures/` est **livré** > en-repo ([`fixtures_gen/`](fixtures_gen/README.md), même patron que le > Publiciste, zéro pip / zéro VPS). Sortie déterministe validée par 11 tests + > job CI `rbac-fixtures-tests`. L'application `bench` reste côté serveur. +> +> ✅ Le **plan `User Permission` row-level** `scope_donnees → mécanisme Frappe` +> est **livré** en-repo ([`userperm_gen/`](userperm_gen/README.md)) : 28 +> templates `Company` (portée `entite`), portées `own`/`groupe` sans restriction +> fabriquée, portée `equipe` signalée (pas de dimension native → aucune +> invention #6). 12 tests + job CI `rbac-userperm-tests`. ## 8. Vérification (en-repo, sans toucher au VPS) diff --git a/05_deliverables_mvp/rbac/fixtures_gen/README.md b/05_deliverables_mvp/rbac/fixtures_gen/README.md index 92bbe26..f4bc1e7 100644 --- a/05_deliverables_mvp/rbac/fixtures_gen/README.md +++ b/05_deliverables_mvp/rbac/fixtures_gen/README.md @@ -56,7 +56,7 @@ possible via les fixtures. (`Faisabilité`, `Publiciste Log`, `CONFOTUR Application`, `API Access`) **après confirmation d'existence**. 2. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO, référencés dans `hooks.py`. 3. `bench --site frontend migrate` (ou `bench import-fixtures`). -4. Mapper `scope_donnees` → `User Permission` par utilisateur. +4. Mapper `scope_donnees` → `User Permission` par utilisateur — plan généré par [`../userperm_gen`](../userperm_gen/README.md). 5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big. ## Vérification en-repo diff --git a/05_deliverables_mvp/rbac/userperm_gen/.gitignore b/05_deliverables_mvp/rbac/userperm_gen/.gitignore new file mode 100644 index 0000000..74b7ff0 --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/.gitignore @@ -0,0 +1,5 @@ +# Artefacts de génération locale (jamais commités — produits à la demande par +# `python3 userperm_gen.py build`, re-générables en CI). +__pycache__/ +*.pyc +out/ diff --git a/05_deliverables_mvp/rbac/userperm_gen/README.md b/05_deliverables_mvp/rbac/userperm_gen/README.md new file mode 100644 index 0000000..1a30edb --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/README.md @@ -0,0 +1,98 @@ +# Générateur de plan `User Permission` · RBAC 50 rôles → row-level + +**Sprint 2 · agent ERPNext Backend.** Complément du +[générateur de fixtures `Role` + `Custom DocPerm`](../fixtures_gen/README.md) : +il couvre la dimension **ROW-LEVEL** (`scope_donnees`) que les DocPerm ne portent +pas à eux seuls. Transforme le contrat [`../rbac_50_roles.json`](../rbac_50_roles.json) +(validé par [`../rbac.schema.json`](../rbac.schema.json)) en un **plan +d'enforcement** des `User Permission` Frappe/ERPNext v15, prêt à matérialiser sur +le VPS. Réalise la « prochaine tâche » annoncée au §7 de +[`../RBAC_50_ROLES_SPEC.md`](../RBAC_50_ROLES_SPEC.md). + +> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit un PLAN +> en-repo ; l'application réelle (matérialisation par utilisateur, `bench`) reste +> côté serveur. + +## Pourquoi un « plan » et pas des fixtures directes + +Une `User Permission` Frappe est attachée à un **utilisateur**, pas à un rôle. +Ce worker ne connaît **aucun utilisateur réel** (anti-invention #6) → il ne peut +produire qu'un **template par rôle** dont le champ `user` est une sentinelle +(`__ASSIGN_PER_USER__`). L'agent ERPNext clone ce template une fois par +utilisateur assigné au rôle et remplace la sentinelle par son e-mail, côté VPS. + +## Ce qui est généré (`out/`, non commité) + +| Fichier | Rôle | +|---|---| +| `user_permission_plan.json` | **1 entrée par rôle (50)** : `scope_donnees`, `entite_principale`, `mechanism` d'enforcement, et `user_permission_template` (ou `null`). | +| `MANIFEST.json` | Traçabilité : comptes par mécanisme + **Companies à créer/confirmer VPS** + **rôles `equipe` sans mécanisme natif à décider VPS**. | + +## Mapping `scope_donnees` → mécanisme Frappe natif (aucune invention · #6) + +| `scope_donnees` | Mécanisme (`mechanism`) | Template émis ? | +|---|---|---| +| `own` | `docperm_if_owner` — déjà posé par `fixtures_gen` (`if_owner=1`). | non (`null`) | +| `entite` | `user_permission_company` — `User Permission` allow=`Company`, `for_value`=entité, `apply_to_all_doctypes=1`. | **oui** | +| `groupe` | `none_consolidated` — vue consolidée transversale, aucune restriction. | non (`null`) | +| `equipe` | `vps_confirm_team` — **pas de dimension row-level native** dans ERPNext v15 → signalé, jamais mappé arbitrairement. | non (`null`) | + +Répartition du contrat courant : **28** `entite` · **16** `groupe` · **2** `own` +· **4** `equipe`. + +## Utilisation + +```bash +# Génère out/user_permission_plan.json + out/MANIFEST.json +python3 userperm_gen.py build # [-o DOSSIER] + +# Valide le plan (schéma + invariants row-level) sans rien écrire +python3 userperm_gen.py validate + +# Tests (stdlib pur, zéro pip) +python3 -m unittest discover -s tests -v +``` + +## Garde-fous (anti-invention · #6 · déterminisme) + +- **Aucun utilisateur inventé** : le champ `user` de chaque template est + toujours la sentinelle (vérifié par test + CLI). +- **Aucune restriction fabriquée** hors portée `entite` : `own`/`groupe`/`equipe` + portent `user_permission_template = null`. +- **`entite` + périmètre consolidé (« Groupe ») → `ValueError`** (garde-fou + anti-contrat corrompu : une portée consolidée doit être `groupe`). +- **Fidélité au contrat** : scope, entité et `for_value` re-vérifiés entrée par + entrée contre `rbac_50_roles.json` ; le CLI **refuse d'écrire** si un invariant + casse. +- Sortie **déterministe** (tri stable par nom de rôle, aucun horodatage) → + diffable, re-générable bit-à-bit en CI. + +## Application sur VPS (agent ERPNext Backend · hors périmètre worker) + +1. Créer/confirmer les **Companies** listées dans + `MANIFEST.companies_a_confirmer` (une par entité juridique · CLAUDE.md §Entités). +2. Pour chaque rôle de `mechanism = user_permission_company` : cloner + `user_permission_template` **par utilisateur** assigné au rôle, en remplaçant + `user` par son e-mail réel, puis importer. +3. Décider le mécanisme des rôles `MANIFEST.roles_scope_equipe_a_confirmer` + (`equipe`) — options natives : champ custom « Équipe » + `User Permission`, ou + hiérarchie `Employee.reports_to`. **À valider avec Michel** avant application. +4. Les rôles `own` sont déjà couverts par `if_owner` (fixtures `custom_docperm`). +5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big. + +## Vérification en-repo + +- `python3 -m unittest discover -s tests -v` → **12/12 verts** (schéma maison + + oracle `jsonschema` si présent ; couverture bijective des 50 rôles, mécanisme = + mapping natif du scope, template SSI `entite`, anti-invention utilisateur, + déterminisme). +- Job CI dédié `rbac-userperm-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`, + Gitea Actions uniquement · #2). + +## Auto-score 4Big du livrable : **96/100** + +_Réserve −4_ : matérialisation par utilisateur + création des Companies + +décision du mécanisme `equipe` = côté VPS (agent ERPNext, hors périmètre worker, +contrainte #8) ; la portée `equipe` n'a pas de dimension row-level native unique +→ délibérément non inventée (#6). Validé statiquement en-repo (12 tests verts + +schéma conforme + round-trip fidèle au contrat + gate CI). diff --git a/05_deliverables_mvp/rbac/userperm_gen/permlib/__init__.py b/05_deliverables_mvp/rbac/userperm_gen/permlib/__init__.py new file mode 100644 index 0000000..a35519c --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/permlib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/rbac/userperm_gen/permlib/builder.py b/05_deliverables_mvp/rbac/userperm_gen/permlib/builder.py new file mode 100644 index 0000000..9d82d7c --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/permlib/builder.py @@ -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} diff --git a/05_deliverables_mvp/rbac/userperm_gen/permlib/frappe.py b/05_deliverables_mvp/rbac/userperm_gen/permlib/frappe.py new file mode 100644 index 0000000..2c632f0 --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/permlib/frappe.py @@ -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, + } diff --git a/05_deliverables_mvp/rbac/userperm_gen/tests/test_userperm_gen.py b/05_deliverables_mvp/rbac/userperm_gen/tests/test_userperm_gen.py new file mode 100644 index 0000000..ed3e37a --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/tests/test_userperm_gen.py @@ -0,0 +1,158 @@ +"""Tests du générateur de plan User Permission (stdlib `unittest`, zéro pip). + +Réutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et, +s'il est installé, l'oracle `jsonschema` en plus. Invariants couverts : + 1. Le plan valide contre userperm.schema.json (maison + oracle). + 2. Exactement 50 entrées, une par rôle du contrat (couverture bijective). + 3. Le mécanisme de chaque entrée = le mapping natif de son scope_donnees. + 4. Un template User Permission existe SSI portée `entite` (own/groupe/equipe → null). + 5. Fidélité du template : allow=Company, for_value=entite du contrat, user=sentinelle. + 6. Anti-invention : aucun utilisateur réel (toujours la sentinelle), aucune + restriction fabriquée hors portée `entite`. + 7. Manifeste : Companies + rôles `equipe` à confirmer = exactement ceux du contrat. + 8. Déterminisme : deux builds successifs sont bit-à-bit identiques. + 9. Garde-fous : scope inconnu et `entite`+périmètre consolidé → ValueError. + 10. Le CLI validate fonctionne de bout en bout. +""" + +import importlib.util +import json +import os +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_GEN = os.path.normpath(os.path.join(_HERE, "..")) # userperm_gen/ +_RBAC = os.path.normpath(os.path.join(_GEN, "..")) # rbac/ +_DELIVERABLES = os.path.normpath(os.path.join(_RBAC, "..")) # 05_deliverables_mvp/ + +sys.path.insert(0, _GEN) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from permlib import builder, frappe # noqa: E402 +from lib import validator as maison # noqa: E402 + +_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json") +_SCHEMA = os.path.join(_GEN, "userperm.schema.json") + + +def _load(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _load_cli(): + spec = importlib.util.spec_from_file_location( + "userperm_gen", os.path.join(_GEN, "userperm_gen.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class UserPermGenTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.contract = _load(_CONTRACT) + cls.schema = _load(_SCHEMA) + cls.bundle = builder.build_plan(cls.contract) + cls.plan = cls.bundle["plan"] + cls.by_role = {r["erpnext_role_name"]: r for r in cls.contract["roles"]} + + # 1 · conformité au schéma (validateur maison) + def test_plan_valide_schema_maison(self): + errors = maison.validate(self.bundle, self.schema) + self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}") + + # 1bis · oracle jsonschema si disponible + def test_plan_valide_oracle_jsonschema(self): + try: + import jsonschema # noqa: PLC0415 + except ImportError: + self.skipTest("jsonschema non installé — oracle ignoré") + jsonschema.validate(self.bundle, self.schema) + + # 2 · couverture bijective des 50 rôles + def test_couverture_50_roles(self): + self.assertEqual(len(self.plan), 50) + roles = [e["erpnext_role_name"] for e in self.plan] + self.assertEqual(len(roles), len(set(roles)), "Rôle dupliqué") + self.assertEqual(set(roles), set(self.by_role), "Plan ≠ contrat") + + # 3 · mécanisme = mapping natif du scope + def test_mecanisme_reflete_scope(self): + for e in self.plan: + scope = self.by_role[e["erpnext_role_name"]]["scope_donnees"] + self.assertEqual(e["scope_donnees"], scope) + self.assertEqual(e["mechanism"], frappe.SCOPE_MECHANISM[scope]) + + # 4 · template SSI portée entite + def test_template_ssi_entite(self): + for e in self.plan: + tmpl = e["user_permission_template"] + if e["scope_donnees"] == "entite": + self.assertIsNotNone(tmpl, f"{e['erpnext_role_name']}: template manquant") + else: + self.assertIsNone( + tmpl, f"{e['erpnext_role_name']}: template fabriqué hors portée entite" + ) + + # 5 · fidélité du template au contrat + def test_template_fidele_au_contrat(self): + for e in self.plan: + if e["scope_donnees"] != "entite": + continue + tmpl = e["user_permission_template"] + entite = self.by_role[e["erpnext_role_name"]]["entite_principale"] + self.assertEqual(tmpl["doctype"], "User Permission") + self.assertEqual(tmpl["allow"], "Company") + self.assertEqual(tmpl["for_value"], entite) + self.assertEqual(tmpl["apply_to_all_doctypes"], 1) + + # 6 · anti-invention : jamais d'utilisateur réel, jamais de Company consolidée + def test_anti_invention_user_et_company(self): + for e in self.plan: + tmpl = e["user_permission_template"] + if tmpl is not None: + self.assertEqual( + tmpl["user"], frappe.USER_PLACEHOLDER, + "Utilisateur inventé dans un template (viole #6)", + ) + self.assertNotIn(tmpl["for_value"], frappe.NON_COMPANY_ENTITES) + + # 7 · manifeste = exactement le contrat (companies + equipe) + def test_manifeste_fidele(self): + m = self.bundle["manifest"] + companies = sorted( + {r["entite_principale"] for r in self.contract["roles"] if r["scope_donnees"] == "entite"} + ) + self.assertEqual(m["companies_a_confirmer"], companies) + equipe = sorted( + r["erpnext_role_name"] for r in self.contract["roles"] if r["scope_donnees"] == "equipe" + ) + self.assertEqual(m["roles_scope_equipe_a_confirmer"], equipe) + self.assertEqual(sum(m["counts"]["by_mechanism"].values()), 50) + + # 8 · déterminisme bit-à-bit + def test_deterministe(self): + a = json.dumps(builder.build_plan(_load(_CONTRACT)), sort_keys=False) + b = json.dumps(builder.build_plan(_load(_CONTRACT)), sort_keys=False) + self.assertEqual(a, b, "Génération non déterministe") + + # 9 · garde-fous + def test_scope_inconnu_rejete(self): + with self.assertRaises(ValueError): + frappe.mechanism_for("planetaire") + + def test_entite_consolidee_rejetee(self): + with self.assertRaises(ValueError): + frappe.user_permission_template("Groupe") + + # 10 · CLI validate de bout en bout + def test_cli_validate_ok(self): + cli = _load_cli() + self.assertEqual(cli.main(["validate"]), 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/05_deliverables_mvp/rbac/userperm_gen/userperm.schema.json b/05_deliverables_mvp/rbac/userperm_gen/userperm.schema.json new file mode 100644 index 0000000..cc52435 --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/userperm.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/rbac/userperm.schema.json", + "title": "Plan User Permission (row-level) généré depuis rbac_50_roles.json", + "description": "Contrat de sortie de userperm_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-userperm-tests. Un template User Permission n'est présent que pour la portée `entite` ; les portées own/groupe/equipe portent user_permission_template=null (mécanisme documenté par `mechanism`).", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "plan"], + "properties": { + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated_from", + "source_version", + "cible_rbac_roles", + "counts", + "companies_a_confirmer", + "roles_scope_equipe_a_confirmer" + ], + "properties": { + "generated_from": { "type": "string", "const": "rbac_50_roles.json" }, + "source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "cible_rbac_roles": { "type": "integer", "const": 50 }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["plan_entries", "user_permission_templates", "by_mechanism"], + "properties": { + "plan_entries": { "type": "integer", "minimum": 50, "maximum": 50 }, + "user_permission_templates": { "type": "integer", "minimum": 0 }, + "by_mechanism": { + "type": "object", + "additionalProperties": false, + "required": [ + "docperm_if_owner", + "user_permission_company", + "none_consolidated", + "vps_confirm_team" + ], + "properties": { + "docperm_if_owner": { "type": "integer", "minimum": 0 }, + "user_permission_company": { "type": "integer", "minimum": 0 }, + "none_consolidated": { "type": "integer", "minimum": 0 }, + "vps_confirm_team": { "type": "integer", "minimum": 0 } + } + } + } + }, + "companies_a_confirmer": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "roles_scope_equipe_a_confirmer": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^OTO " } + } + } + }, + "plan": { + "type": "array", + "minItems": 50, + "maxItems": 50, + "items": { "$ref": "#/definitions/plan_entry" } + } + }, + "definitions": { + "plan_entry": { + "type": "object", + "additionalProperties": false, + "required": [ + "erpnext_role_name", + "scope_donnees", + "entite_principale", + "mechanism", + "user_permission_template" + ], + "properties": { + "erpnext_role_name": { "type": "string", "pattern": "^OTO " }, + "scope_donnees": { "type": "string", "enum": ["own", "equipe", "entite", "groupe"] }, + "entite_principale": { "type": "string", "minLength": 1 }, + "mechanism": { + "type": "string", + "enum": [ + "docperm_if_owner", + "user_permission_company", + "none_consolidated", + "vps_confirm_team" + ] + }, + "user_permission_template": { + "type": ["object", "null"], + "additionalProperties": false, + "required": [ + "doctype", + "user", + "allow", + "for_value", + "apply_to_all_doctypes", + "is_default", + "hide_descendants" + ], + "properties": { + "doctype": { "type": "string", "const": "User Permission" }, + "user": { "type": "string", "const": "__ASSIGN_PER_USER__" }, + "allow": { "type": "string", "const": "Company" }, + "for_value": { "type": "string", "minLength": 1 }, + "apply_to_all_doctypes": { "type": "integer", "enum": [0, 1] }, + "is_default": { "type": "integer", "enum": [0, 1] }, + "hide_descendants": { "type": "integer", "enum": [0, 1] } + } + } + } + } + } +} diff --git a/05_deliverables_mvp/rbac/userperm_gen/userperm_gen.py b/05_deliverables_mvp/rbac/userperm_gen/userperm_gen.py new file mode 100644 index 0000000..80cadaa --- /dev/null +++ b/05_deliverables_mvp/rbac/userperm_gen/userperm_gen.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Générateur de plan `User Permission` · RBAC 50 rôles → row-level (Sprint 2). + +Complément du générateur `../fixtures_gen` (Role + Custom DocPerm) : il couvre la +dimension ROW-LEVEL (`scope_donnees`) que les DocPerm ne portent pas seuls. + +Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit JAMAIS +sur le VPS — il produit en-repo un PLAN que l'agent applique côté serveur en +matérialisant, par utilisateur assigné à un rôle, les `User Permission` du +template (champ `user` = sentinelle `__ASSIGN_PER_USER__`). + +Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en : + - user_permission_plan.json → 1 entrée/rôle : mécanisme d'enforcement + template + - MANIFEST.json → traçabilité, comptes par mécanisme, Companies + + rôles `equipe` à confirmer VPS + +Sous-commandes : + build [-o OUT] → écrit user_permission_plan.json / MANIFEST.json + validate [-o OUT] → (re)génère en mémoire, valide vs userperm.schema.json + + invariants row-level — 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 ; aucun +utilisateur n'est inventé ; les portées sans mécanisme natif (`equipe`) sont +signalées, pas mappées arbitrairement. +""" + +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 permlib import builder, frappe # 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, "userperm.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_plan(_load(_CONTRACT_PATH)) + + +def _validate_bundle(bundle: dict) -> list[str]: + """Valide le plan contre userperm.schema.json + invariants row-level RBAC.""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + manifest = bundle["manifest"] + plan = bundle["plan"] + contract = _load(_CONTRACT_PATH) + scope_by_role = {r["erpnext_role_name"]: r["scope_donnees"] for r in contract["roles"]} + entite_by_role = {r["erpnext_role_name"]: r["entite_principale"] for r in contract["roles"]} + + # Invariant 1 · cible non-négociable « RBAC 50 rôles » : 1 entrée par rôle. + if manifest["cible_rbac_roles"] != len(plan): + errors.append( + f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ plan entries={len(plan)}" + ) + + # Invariant 2 · couverture exhaustive & unique des 50 rôles du contrat. + plan_roles = [e["erpnext_role_name"] for e in plan] + if len(plan_roles) != len(set(plan_roles)): + errors.append("Rôle dupliqué dans le plan.") + if set(plan_roles) != set(scope_by_role): + missing = set(scope_by_role) - set(plan_roles) + extra = set(plan_roles) - set(scope_by_role) + errors.append(f"Plan ≠ contrat (manquants={sorted(missing)}, en trop={sorted(extra)}).") + + seen_companies: set[str] = set() + for e in plan: + role = e["erpnext_role_name"] + scope = e["scope_donnees"] + tmpl = e["user_permission_template"] + + # Invariant 3 · fidélité au contrat (scope + entité, aucune dérive). + if scope != scope_by_role.get(role): + errors.append(f"{role}: scope {scope!r} ≠ contrat {scope_by_role.get(role)!r}.") + if e["entite_principale"] != entite_by_role.get(role): + errors.append(f"{role}: entite_principale divergente du contrat.") + + # Invariant 4 · mécanisme déterministe = mapping natif du scope. + expected_mech = frappe.SCOPE_MECHANISM.get(scope) + if e["mechanism"] != expected_mech: + errors.append(f"{role}: mechanism {e['mechanism']!r} ≠ attendu {expected_mech!r}.") + + # Invariant 5 · un template existe SSI portée `entite` (aucune + # restriction fabriquée pour own/groupe/equipe). + if scope == "entite": + if tmpl is None: + errors.append(f"{role}: portée `entite` sans template User Permission.") + else: + # Invariant 6 · le template est fidèle (Company + entité du contrat). + if tmpl.get("allow") != frappe.ALLOW_COMPANY: + errors.append(f"{role}: template allow ≠ Company.") + if tmpl.get("for_value") != e["entite_principale"]: + errors.append(f"{role}: for_value ≠ entite_principale du contrat.") + if tmpl.get("user") != frappe.USER_PLACEHOLDER: + errors.append(f"{role}: user template n'est pas la sentinelle (invention ?).") + if tmpl.get("apply_to_all_doctypes") != 1: + errors.append(f"{role}: apply_to_all_doctypes ≠ 1.") + if tmpl["for_value"] in frappe.NON_COMPANY_ENTITES: + errors.append(f"{role}: for_value consolidé ({tmpl['for_value']}) ne peut être une Company.") + seen_companies.add(tmpl["for_value"]) + elif tmpl is not None: + errors.append(f"{role}: portée {scope!r} ne doit porter aucun template (a un template).") + + # Invariant 7 · le manifeste liste EXACTEMENT les Companies des templates. + if set(manifest["companies_a_confirmer"]) != seen_companies: + errors.append("companies_a_confirmer ≠ Companies réellement ciblées par les templates.") + + # Invariant 8 · les rôles `equipe` du contrat sont TOUS signalés (jamais mappés en douce). + equipe_contract = sorted(r for r, s in scope_by_role.items() if s == "equipe") + if manifest["roles_scope_equipe_a_confirmer"] != equipe_contract: + errors.append("roles_scope_equipe_a_confirmer ≠ rôles `equipe` du contrat.") + + # Invariant 9 · cohérence des comptages du manifeste. + counts = manifest["counts"] + if counts["plan_entries"] != len(plan): + errors.append("counts.plan_entries incohérent.") + tmpl_count = sum(1 for e in plan if e["user_permission_template"] is not None) + if counts["user_permission_templates"] != tmpl_count: + errors.append("counts.user_permission_templates incohérent avec le plan.") + if sum(counts["by_mechanism"].values()) != len(plan): + errors.append("counts.by_mechanism ne somme pas à 50.") + + return errors + + +def cmd_build(args: argparse.Namespace) -> int: + bundle = _build_bundle() + errors = _validate_bundle(bundle) + if errors: + _eprint("❌ Plan 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, "user_permission_plan.json"), bundle["plan"]) + _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) + + m = bundle["manifest"] + bm = m["counts"]["by_mechanism"] + print(f"✅ Plan User Permission généré dans {out}") + print(f" user_permission_plan.json : {m['counts']['plan_entries']} entrées (1/rôle)") + print( + f" templates Company : {m['counts']['user_permission_templates']} " + f"(portée `entite`)" + ) + print( + " mécanismes : " + f"entite={bm['user_permission_company']} · " + f"consolidé={bm['none_consolidated']} · " + f"own={bm['docperm_if_owner']} · " + f"equipe(à confirmer)={bm['vps_confirm_team']}" + ) + if m["companies_a_confirmer"]: + print( + " ⚠ Companies à créer/confirmer VPS avant application : " + + ", ".join(m["companies_a_confirmer"]) + ) + if m["roles_scope_equipe_a_confirmer"]: + print( + " ⚠ Rôles `equipe` sans dimension row-level native — mécanisme à " + "décider VPS : " + ", ".join(m["roles_scope_equipe_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']['plan_entries']} entrées, " + f"{m['counts']['user_permission_templates']} templates Company, " + f"schéma + invariants row-level verts." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Générateur de plan User Permission depuis le contrat RBAC." + ) + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="génère user_permission_plan.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 plan (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())