From 70e022ccd6ef4b240b8a0ff32fd48cad351a0a59 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 04:04:04 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=202=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20Role=20Profile=20par=20portail=20(RBAC=2050=20r?= =?UTF-8?q?=C3=B4les=20=E2=86=92=206=20bundles=20assignables=20ERPNext=20v?= =?UTF-8?q?15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roleprofile_gen/ : profilelib (frappe Role Profile + Has Role natif v15, builder déterministe), roleprofile.schema.json, CLI build/validate refusant d'écrire si invariant cassé, 11 tests stdlib, README, .gitignore (out/). - 6 profils couvrant les 50 rôles de façon bijective (5 portails métier + console technique plateforme). Aucun DocType custom (que du natif). - Anti-invention #6 : rôles 100 % issus du contrat, nom de profil = convention déterministe dérivée de la clé portail. - CI : job rbac-roleprofile-tests ajouté au gate (.gitea/workflows/ci.yml). - Doc : SPEC §7 ét.5 + encart livré. Daily report session 8. - Régression : 83 tests verts (72 + 11). Gate CI local vert. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 20 +- .../daily_reports/2026-07-30-session8.md | 93 +++++++ .../rbac/RBAC_50_ROLES_SPEC.md | 13 +- .../rbac/roleprofile_gen/.gitignore | 5 + .../rbac/roleprofile_gen/README.md | 103 ++++++++ .../roleprofile_gen/profilelib/__init__.py | 5 + .../roleprofile_gen/profilelib/builder.py | 80 ++++++ .../rbac/roleprofile_gen/profilelib/frappe.py | 86 +++++++ .../roleprofile_gen/roleprofile.schema.json | 92 +++++++ .../rbac/roleprofile_gen/roleprofile_gen.py | 229 ++++++++++++++++++ .../tests/test_roleprofile_gen.py | 155 ++++++++++++ 11 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session8.md create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/.gitignore create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/README.md create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/profilelib/__init__.py create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/profilelib/builder.py create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/profilelib/frappe.py create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/roleprofile.schema.json create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/roleprofile_gen.py create mode 100644 05_deliverables_mvp/rbac/roleprofile_gen/tests/test_roleprofile_gen.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 207fcd5..94a5498 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -125,10 +125,28 @@ jobs: - name: Tests générateur de plan User Permission run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur de Role Profile (Sprint 2 · ERPNext Backend) : le contrat + # rbac_50_roles.json → un `Role Profile` ERPNext v15 natif par portail (bundle + # de rôles assignable en un geste). Valide le bundle contre + # roleprofile.schema.json + invariants (couverture bijective des 50 rôles, un + # profil par portail, cohérence portail, anti-invention). stdlib pur. + # -------------------------------------------------------------------------- + rbac-roleprofile-tests: + name: RBAC · Role Profile (bundles par portail) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/rbac/roleprofile_gen + steps: + - uses: actions/checkout@v4 + - name: Tests générateur de Role Profile + 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, rbac-userperm-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-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-session8.md b/05_deliverables_mvp/daily_reports/2026-07-30-session8.md new file mode 100644 index 0000000..0f93124 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session8.md @@ -0,0 +1,93 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 8) + +**Session** : `20260730_035651` + +## Tâche exécutée +**Sprint 2 · Livrable ERPNext Backend « Générateur de `Role Profile` par portail +(RBAC 50 rôles → bundles de rôles assignables) »** +(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » · +`RBAC_50_ROLES_SPEC.md` §7 ét. 5 · **prochaine tâche suggérée** par le daily +report session 7 : « générateur `Role Profile` par portail »). + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily report session 7, + `rbac_50_roles.json` + `rbac.schema.json`, les deux générateurs existants + (`fixtures_gen` Role+DocPerm, `userperm_gen` row-level), le validateur maison + Publiciste, le patron CI. +- État Sprint 2 : contrat RBAC (s4), Publiciste (s3), Faisabilité (s5), fixtures + `Role`+`Custom DocPerm` (s6), plan `User Permission` row-level (s7) livrés. + Frontend/CRM dépendent de layouts LIVE (VPS) → hors périmètre worker. +- **Priorité évidente** (SPEC §7 + report s7) : le **bundle assignable** manquant. + `fixtures_gen` = « quels verbes sur quel DocType », `userperm_gen` = « sur + quelles lignes » ; il manquait « comment donner à un utilisateur tous les rôles + de son portail en un geste ». ERPNext v15 le fait nativement avec le DocType + `Role Profile`. 100 % autorable sans VPS/pip/API, même patron que les deux + générateurs précédents, sur le chemin critique de l'application RBAC (S4). + +## Réalisé — module `05_deliverables_mvp/rbac/roleprofile_gen/` +- `profilelib/frappe.py` — modèle **Frappe/ERPNext v15 natif** : DocTypes + `Role Profile` + child `Has Role` (aucun DocType custom). Nom de profil = + **convention de nommage déterministe** `OTO Portail ` dérivée de la + clé `portail` (aucun libellé métier fabriqué, #6). Garde-fous : portail vide et + profil sans rôle → `ValueError`. +- `profilelib/builder.py` — `build_bundle()` **déterministe** : regroupe les 50 + rôles par `portail` (profils triés par portail, `Has Role` triés par nom de + rôle) → **6 profils** + manifeste (comptes, flag métier vs technique, mapping + `portail → role_profile`). +- `roleprofile_gen.py` — CLI `build` / `validate`. **Refuse d'écrire** si un + invariant casse (schéma, couverture **bijective** des 50 rôles, un profil par + portail distinct, cohérence portail intra-profil, fidélité du manifeste, + cohérence des comptes, tris déterministes). +- `roleprofile.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté + par le **validateur maison Publiciste**, zéro pip). +- `tests/test_roleprofile_gen.py` — **11 tests `unittest` (stdlib pur)** : schéma + (maison + oracle `jsonschema` si présent), couverture bijective, un profil par + portail, cohérence portail, manifeste fidèle, anti-invention (que du natif v15), + déterminisme, garde-fous. +- **CI** : job `rbac-roleprofile-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` (nouvelle ét. 5 + encart livré, + renumérotation de la vérification HTTP en ét. 6). + +## Anti-invention (#6) appliqué +- 100 % des rôles (et leur portail) proviennent du contrat `rbac_50_roles.json`. +- **Que du natif ERPNext v15** : `Role Profile` + `Has Role` → aucun DocType + custom à confirmer côté VPS (contrainte #1 « ERPNext natif »). +- Le nom du profil est une convention déterministe dérivée de la clé `portail`, + pas un libellé métier inventé ; la clé brute reste tracée dans le manifeste. +- Le flag `metier` reflète strictement l'appartenance à `portails_business` du + contrat (les 6 portails = 5 métier + la console technique `plateforme`). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **11/11 tests verts** (schéma maison + oracle `jsonschema`). Génération réelle : + **6 profils** (Ventes 12, Construction 10, Direction 9, Compta 8, Achat 5 = + métier ; Plateforme 6 = technique), **50/50 rôles couverts** de façon bijective. +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`, + `check_docs.sh` (0 lien cassé, mention score présente), YAML `ci.yml` valide. +- **Régression** : 23 Publiciste + 10 RBAC + 11 fixtures + 12 userperm + 16 + Faisabilité + 11 roleprofile = **83 tests verts**. + +## Note de cohérence documentaire +- Le tableau §3 du SPEC annonçait direction=10 / plateforme=5 ; le JSON réel porte + direction=9 / plateforme=6. Le SPEC note explicitement que **le JSON est la + source de vérité** → le générateur est data-driven (compte réel). Pas de + correction du tableau §3 (marqué « indicatif ») pour ne pas dévier du périmètre. + +## Non fait (hors périmètre worker · VPS) +- Import `bench migrate` des fixtures + affectation `User.role_profile_name` par + utilisateur → agent ERPNext Backend (SPEC §7 ét. 3/5). + +## Prochaine tâche suggérée +- ERPNext S2 : agrégateur `MANIFEST` unifié Role + DocPerm + User Permission + + Role Profile (vue/ordre d'application VPS unique), pour clore le volet RBAC. +- 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 Role Profile : 96/100.** +Réserve −4 : import `bench` + affectation `role_profile_name` par utilisateur = +côté VPS (agent ERPNext, hors périmètre worker, #8). Validé statiquement en-repo +(11 tests verts + schéma conforme + couverture bijective fidèle au contrat + gate +CI vert · 83 tests de régression au total). diff --git a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md index 2763176..9b9165a 100644 --- a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md +++ b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md @@ -136,7 +136,12 @@ cible côté serveur (`erpnext-backend-1`) : 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. +5. Générer les **`Role Profile`** (bundles de rôles par portail) — **livré** : + [`roleprofile_gen/`](roleprofile_gen/README.md) (`python3 roleprofile_gen.py + build`). Importer `role_profile.json` **après** les fixtures `Role` (étape 3), + puis affecter `User.role_profile_name` selon `MANIFEST.profiles` → tous les + rôles d'un portail appliqués en un geste. +6. 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 @@ -148,6 +153,12 @@ cible côté serveur (`erpnext-backend-1`) : > 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`. +> +> ✅ Les **`Role Profile`** (bundles de rôles par portail) sont **livrés** en-repo +> ([`roleprofile_gen/`](roleprofile_gen/README.md)) : **6 profils** (5 portails +> métier + console technique `plateforme`) couvrant les **50 rôles de façon +> bijective**, 100 % DocTypes natifs v15 (`Role Profile` + `Has Role`), aucun +> DocType custom à confirmer. 11 tests + job CI `rbac-roleprofile-tests`. ## 8. Vérification (en-repo, sans toucher au VPS) diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/.gitignore b/05_deliverables_mvp/rbac/roleprofile_gen/.gitignore new file mode 100644 index 0000000..bb7490a --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/.gitignore @@ -0,0 +1,5 @@ +# Artefacts de génération locale (jamais commités — produits à la demande par +# `python3 roleprofile_gen.py build`, re-générables en CI). +__pycache__/ +*.pyc +out/ diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/README.md b/05_deliverables_mvp/rbac/roleprofile_gen/README.md new file mode 100644 index 0000000..c376544 --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/README.md @@ -0,0 +1,103 @@ +# Générateur de `Role Profile` · RBAC 50 rôles → bundles par portail + +**Sprint 2 · agent ERPNext Backend.** Troisième et dernier maillon RBAC en-repo, +complément des générateurs [`Role` + `Custom DocPerm`](../fixtures_gen/README.md) +(quels verbes sur quel DocType) et [plan `User Permission`](../userperm_gen/README.md) +(sur quelles lignes). Il produit les **bundles assignables** : un `Role Profile` +ERPNext v15 **natif** par portail, qui permet d'attribuer à un utilisateur, en un +seul geste (`User.role_profile_name`), l'ensemble des rôles de SON portail. +Transforme le contrat [`../rbac_50_roles.json`](../rbac_50_roles.json) (validé par +[`../rbac.schema.json`](../rbac.schema.json)) en fixtures Frappe prêtes à importer. + +> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit un bundle de +> fixtures en-repo ; l'application réelle (`bench migrate`) reste côté serveur. + +## Pourquoi un `Role Profile` (et pas juste des rôles) + +Assigner à la main les 5 à 12 rôles d'un portail à chaque nouvel utilisateur est +fastidieux et source d'erreurs. ERPNext v15 fournit nativement le DocType +`Role Profile` (contrainte #1 « ERPNext natif = priorité absolue ») : un bundle +nommé de rôles (`Has Role`) qu'on affecte en un champ. Les **5 portails métier** +(roadmap Sprint 4 « 5 portails rôle ») + la **console technique `plateforme`** +donnent **6 profils** couvrant les 50 rôles de façon **bijective** (chaque rôle +dans exactement un profil, puisque chaque rôle a exactement un `portail`). + +## Ce qui est généré (`out/`, non commité) + +| Fichier | Rôle | +|---|---| +| `role_profile.json` | **1 fixture `Role Profile` par portail** ; chacun bundle ses rôles via des lignes enfant `Has Role`. | +| `MANIFEST.json` | Traçabilité : comptes (profils, métier vs technique, rôles couverts) + mapping `portail → role_profile`. | + +## Profils générés (contrat courant · source de vérité = le JSON) + +| `role_profile` | Portail | Type | Nb rôles | +|---|---|---|---| +| `OTO Portail Ventes` | ventes | métier | 12 | +| `OTO Portail Construction` | construction | métier | 10 | +| `OTO Portail Direction` | direction | métier | 9 | +| `OTO Portail Compta` | compta | métier | 8 | +| `OTO Portail Achat` | achat | métier | 5 | +| `OTO Portail Plateforme` | plateforme | technique | 6 | +| | | **Total** | **50** | + +> Le nom du profil est une **convention de nommage déterministe** dérivée de la +> clé `portail` (`OTO Portail `) — aucun libellé métier ni chiffre +> fabriqué (anti-invention #6). Les comptes viennent du contrat, pas de cette +> table (cf. SPEC §3 : le JSON reste la source de vérité). + +## Utilisation + +```bash +# Génère out/role_profile.json + out/MANIFEST.json +python3 roleprofile_gen.py build # [-o DOSSIER] + +# Valide le bundle (schéma + invariants) sans rien écrire +python3 roleprofile_gen.py validate + +# Tests (stdlib pur, zéro pip) +python3 -m unittest discover -s tests -v +``` + +## Garde-fous (anti-invention · #6 · déterminisme) + +- **Couverture bijective** re-vérifiée : chaque rôle du contrat dans exactement + un profil, aucun rôle inventé, aucun manquant ; le CLI **refuse d'écrire** si un + invariant casse. +- **Que du natif v15** : DocTypes `Role Profile` + `Has Role` uniquement → aucun + DocType custom à confirmer côté VPS (contrairement aux `Custom DocPerm`). +- **Cohérence portail** : un profil ne mélange jamais deux portails ; son nom + correspond à la convention pour ce portail. +- **Fidélité manifeste** : flag `metier` = appartenance à `portails_business` ; + comptes re-vérifiés profil par profil. +- Sortie **déterministe** (profils triés par portail, `Has Role` triés 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. Importer d'abord les fixtures `Role` ([`fixtures_gen`](../fixtures_gen/README.md)) + — les `Has Role` de chaque profil référencent des `Role` qui doivent exister. +2. Déposer `role_profile.json` dans `fixtures/` de l'app OTO (`hooks.py`), puis + `bench --site frontend migrate`. +3. À la création d'un utilisateur, renseigner `role_profile_name` avec le profil + de son portail (mapping dans `MANIFEST.profiles`) → tous les rôles du portail + sont appliqués d'un coup, puis matérialiser les `User Permission` par + utilisateur ([`userperm_gen`](../userperm_gen/README.md)). +4. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big. + +## Vérification en-repo + +- `python3 -m unittest discover -s tests -v` → **11/11 verts** (schéma maison + + oracle `jsonschema` si présent ; couverture bijective des 50 rôles, un profil + par portail, cohérence portail, manifeste fidèle, anti-invention, déterminisme). +- Génération réelle : **6 profils** (5 métier + 1 technique), **50/50 rôles** + couverts. +- Job CI dédié `rbac-roleprofile-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`, + Gitea Actions uniquement · #2). + +## Auto-score 4Big du livrable : **96/100** + +_Réserve −4_ : l'import `bench` + l'affectation `role_profile_name` par +utilisateur restent côté VPS (agent ERPNext, hors périmètre worker, contrainte +#8). Validé statiquement en-repo (11 tests verts + schéma conforme + couverture +bijective fidèle au contrat + gate CI). diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/__init__.py b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/__init__.py new file mode 100644 index 0000000..e66d0b6 --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/__init__.py @@ -0,0 +1,5 @@ +"""Bibliothèque du générateur de `Role Profile` (bundles de rôles par portail). + +`frappe.py` → connaissance ERPNext v15 native (DocTypes `Role Profile` / `Has Role`). +`builder.py` → construction déterministe du bundle depuis `rbac_50_roles.json`. +""" diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/builder.py b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/builder.py new file mode 100644 index 0000000..1b03175 --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/builder.py @@ -0,0 +1,80 @@ +"""Construction des `Role Profile` 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 « bundle » déterministe : + - role_profile → 1 fixture `Role Profile` par portail distinct du contrat, + chacun bundlant les `erpnext_role_name` de ses rôles. + - manifest → traçabilité (comptes, portails métier vs technique, mapping + portail → profil), 100 % dérivé du contrat (contrainte #6). + +Le contrat porte 6 portails distincts (5 métier listés dans `portails_business` ++ la console technique `plateforme`). Chaque rôle a EXACTEMENT un `portail` → +la couverture des 50 rôles par les profils est bijective (invariant vérifié par +le CLI). La source de vérité des comptes est le JSON, pas la doc (SPEC §3). + +Déterminisme : profils triés par clé de portail, rôles triés par nom Frappe → +sortie reproductible bit-à-bit à contrat constant (diffable, re-générable en CI +sans horodatage volatil). +""" + +from __future__ import annotations + +from typing import Any + +from . import frappe + + +def _roles_by_portail(contract: dict) -> dict[str, list[str]]: + """Regroupe les `erpnext_role_name` par `portail` (ordre trié, stable).""" + grouped: dict[str, list[str]] = {} + for r in contract["roles"]: + portail = r["portail"] + name = r["erpnext_role_name"] + grouped.setdefault(portail, []).append(name) + for portail in grouped: + names = grouped[portail] + if len(names) != len(set(names)): + raise ValueError(f"Rôle dupliqué dans le portail {portail!r}.") + names.sort() + return grouped + + +def build_bundle(contract: dict) -> dict[str, Any]: + """Transforme le contrat RBAC en bundle de `Role Profile` déterministe.""" + grouped = _roles_by_portail(contract) + portails_metier = set(contract.get("portails_business", [])) + + role_profiles: list[dict] = [] + profiles_meta: list[dict] = [] + for portail in sorted(grouped): + role_names = grouped[portail] + role_profiles.append(frappe.role_profile_fixture(portail, role_names)) + profiles_meta.append( + { + "portail": portail, + "role_profile": frappe.role_profile_name(portail), + "metier": portail in portails_metier, + "nb_roles": len(role_names), + } + ) + + nb_metier = sum(1 for p in profiles_meta if p["metier"]) + manifest = { + "generated_from": "rbac_50_roles.json", + "source_version": contract["version"], + "cible_rbac_roles": contract["cible_rbac_roles"], + "counts": { + "role_profiles": len(role_profiles), + "portails_metier": nb_metier, + "portails_techniques": len(role_profiles) - nb_metier, + "roles_couverts": sum(p["nb_roles"] for p in profiles_meta), + }, + # Mapping portail → profil (métier vs console technique `plateforme`), + # pour que l'agent ERPNext assigne le bon profil à chaque utilisateur. + "profiles": profiles_meta, + } + + return { + "manifest": manifest, + "role_profile": role_profiles, + } diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/frappe.py b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/frappe.py new file mode 100644 index 0000000..9875b1f --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/profilelib/frappe.py @@ -0,0 +1,86 @@ +"""Modèle Frappe/ERPNext v15 : mapping `portail` RBAC → `Role Profile`. + +Pourquoi ce module en plus de `../fixtures_gen` et `../userperm_gen` +------------------------------------------------------------------- +`fixtures_gen` répond à « QUELS verbes sur QUEL DocType » (Role + Custom DocPerm). +`userperm_gen` répond à « SUR QUELLES LIGNES » (scope_donnees → User Permission). +Il manque le **bundle assignable** : comment donner à un utilisateur, en UN seul +geste, l'ensemble des rôles de SON portail ? ERPNext v15 le fait nativement avec +le DocType `Role Profile` (contrainte #1 « ERPNext natif = priorité absolue » : +on n'invente aucun mécanisme de regroupement externe). + +Référence Frappe (v15, `frappe/core/doctype/role_profile`) : + - DocType `Role Profile` : champ `role_profile` (Data, nom du profil, unique) + + table `roles` (child `Has Role`). Assigné à un utilisateur via le champ + `User.role_profile_name` (ou la table `role_profiles`) → applique d'un coup + tous les rôles du profil (comportement natif v15). + - Child `Has Role` : un champ `role` (Link → `Role`) par ligne. Frappe + auto-nomme la ligne et pose `parent`/`idx` à l'import → on ne les invente pas. + +Anti-invention (#6) : les rôles proviennent tous du contrat `rbac_50_roles.json` +(champ `erpnext_role_name`). Le NOM du profil est une convention de nommage +déterministe dérivée de la clé `portail` (aucun chiffre ni libellé métier +fabriqué), documentée ci-dessous. + +Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables. +""" + +from __future__ import annotations + +from typing import Any + +# --------------------------------------------------------------------------- # +# Convention de nommage du profil (non un chiffre métier — une étiquette stable +# dérivée de la clé `portail`). Préfixe « OTO Portail » cohérent avec le préfixe +# « OTO » déjà porté par les rôles du contrat (anti-collision desk natif). +# --------------------------------------------------------------------------- # +ROLE_PROFILE_PREFIX = "OTO Portail" + +# DocTypes ERPNext v15 natifs mobilisés (aucun DocType custom → rien à confirmer +# côté VPS pour ce livrable, contrairement aux `Custom DocPerm`). +ROLE_PROFILE_DOCTYPE = "Role Profile" +HAS_ROLE_DOCTYPE = "Has Role" + + +def role_profile_name(portail: str) -> str: + """Nom Frappe déterministe d'un `Role Profile` pour une clé de portail. + + « ventes » → « OTO Portail Ventes ». La casse de titre est purement + cosmétique et reproductible ; la clé brute reste tracée dans le manifeste. + """ + if not portail: + raise ValueError("portail vide : impossible de nommer le Role Profile.") + return f"{ROLE_PROFILE_PREFIX} {portail.capitalize()}" + + +def has_role_row(erpnext_role_name: str) -> dict[str, Any]: + """Construit une ligne enfant `Has Role` (référence un `Role` existant). + + `parent`/`idx`/`name` sont volontairement omis → Frappe les pose à l'import + (on n'invente aucun identifiant de ligne). + """ + return { + "doctype": HAS_ROLE_DOCTYPE, + "parentfield": "roles", + "parenttype": ROLE_PROFILE_DOCTYPE, + "role": erpnext_role_name, + } + + +def role_profile_fixture( + portail: str, role_names: list[str] +) -> dict[str, Any]: + """Construit un enregistrement de fixture `Role Profile` avec ses `Has Role`. + + `role_names` est supposé déjà trié (déterminisme géré par le builder). Le + champ `name` = `role_profile` (Frappe nomme un Role Profile par son libellé). + """ + if not role_names: + raise ValueError(f"portail {portail!r} sans rôle : Role Profile vide refusé.") + name = role_profile_name(portail) + return { + "doctype": ROLE_PROFILE_DOCTYPE, + "name": name, + "role_profile": name, + "roles": [has_role_row(r) for r in role_names], + } diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile.schema.json b/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile.schema.json new file mode 100644 index 0000000..93f96a3 --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/rbac/roleprofile.schema.json", + "title": "Bundle Role Profile généré depuis rbac_50_roles.json", + "description": "Contrat de sortie de roleprofile_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-roleprofile-tests. Un Role Profile ERPNext v15 natif par portail distinct du contrat ; chaque profil bundle les rôles (Has Role) de son portail. Couverture bijective des 50 rôles.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "role_profile"], + "properties": { + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated_from", + "source_version", + "cible_rbac_roles", + "counts", + "profiles" + ], + "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": [ + "role_profiles", + "portails_metier", + "portails_techniques", + "roles_couverts" + ], + "properties": { + "role_profiles": { "type": "integer", "minimum": 1 }, + "portails_metier": { "type": "integer", "minimum": 0 }, + "portails_techniques": { "type": "integer", "minimum": 0 }, + "roles_couverts": { "type": "integer", "minimum": 50, "maximum": 50 } + } + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/profile_meta" } + } + } + }, + "role_profile": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/role_profile_fixture" } + } + }, + "definitions": { + "profile_meta": { + "type": "object", + "additionalProperties": false, + "required": ["portail", "role_profile", "metier", "nb_roles"], + "properties": { + "portail": { "type": "string", "minLength": 1 }, + "role_profile": { "type": "string", "pattern": "^OTO Portail " }, + "metier": { "type": "boolean" }, + "nb_roles": { "type": "integer", "minimum": 1 } + } + }, + "role_profile_fixture": { + "type": "object", + "additionalProperties": false, + "required": ["doctype", "name", "role_profile", "roles"], + "properties": { + "doctype": { "type": "string", "const": "Role Profile" }, + "name": { "type": "string", "pattern": "^OTO Portail " }, + "role_profile": { "type": "string", "pattern": "^OTO Portail " }, + "roles": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/has_role_row" } + } + } + }, + "has_role_row": { + "type": "object", + "additionalProperties": false, + "required": ["doctype", "parentfield", "parenttype", "role"], + "properties": { + "doctype": { "type": "string", "const": "Has Role" }, + "parentfield": { "type": "string", "const": "roles" }, + "parenttype": { "type": "string", "const": "Role Profile" }, + "role": { "type": "string", "pattern": "^OTO " } + } + } + } +} diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile_gen.py b/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile_gen.py new file mode 100644 index 0000000..ddbbf65 --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/roleprofile_gen.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Générateur de `Role Profile` par portail · RBAC 50 rôles (Sprint 2). + +Troisième et dernier maillon RBAC en-repo, complément de `../fixtures_gen` +(Role + Custom DocPerm) et `../userperm_gen` (row-level) : il produit les +**bundles assignables** — un `Role Profile` ERPNext v15 natif par portail — qui +permettent à l'agent ERPNext d'attribuer à un utilisateur, en un seul geste, +tous les rôles de SON portail (champ `User.role_profile_name`). + +Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit JAMAIS +sur le VPS — il produit en-repo un bundle de fixtures que l'agent dépose dans +`fixtures/` de l'app OTO puis applique via `bench migrate`. + +Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en : + - role_profile.json → 1 fixture `Role Profile` par portail (+ ses `Has Role`) + - MANIFEST.json → traçabilité : comptes, portails métier vs technique, + mapping portail → profil + +Sous-commandes : + build [-o OUT] → écrit role_profile.json / MANIFEST.json + validate [-o OUT] → (re)génère en mémoire, valide vs roleprofile.schema.json + + invariants — sort en erreur si un invariant casse. + +Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable. +Anti-invention (#6) : 100 % des rôles proviennent du contrat ; le nom du profil +est une convention déterministe dérivée de la clé `portail` (aucun libellé +métier fabriqué) ; aucun DocType custom (que du natif v15). +""" + +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 profilelib 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, "roleprofile.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 roleprofile.schema.json + invariants RBAC.""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + manifest = bundle["manifest"] + profiles = bundle["role_profile"] + contract = _load(_CONTRACT_PATH) + contract_roles = {r["erpnext_role_name"] for r in contract["roles"]} + portail_of_role = {r["erpnext_role_name"]: r["portail"] for r in contract["roles"]} + portails_metier = set(contract.get("portails_business", [])) + + # Invariant 1 · cible non-négociable « RBAC 50 rôles » : couverture exhaustive. + if manifest["cible_rbac_roles"] != len(contract_roles): + errors.append( + f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ rôles contrat={len(contract_roles)}" + ) + + # Invariant 2 · couverture BIJECTIVE : chaque rôle du contrat dans EXACTEMENT + # un profil, aucun rôle inventé, aucun manquant. + assigned: list[str] = [] + for prof in profiles: + for row in prof["roles"]: + assigned.append(row["role"]) + if len(assigned) != len(set(assigned)): + errors.append("Un rôle apparaît dans plusieurs Role Profile (couverture non bijective).") + if set(assigned) != contract_roles: + missing = sorted(contract_roles - set(assigned)) + extra = sorted(set(assigned) - contract_roles) + errors.append(f"Couverture ≠ contrat (manquants={missing}, en trop={extra}).") + + # Invariant 3 · un profil par portail distinct du contrat, nom déterministe. + contract_portails = {r["portail"] for r in contract["roles"]} + seen_names: set[str] = set() + for prof in profiles: + name = prof["name"] + if name != prof["role_profile"]: + errors.append(f"{name}: name ≠ role_profile.") + if name in seen_names: + errors.append(f"Role Profile dupliqué : {name}.") + seen_names.add(name) + # Chaque rôle du profil appartient bien au même portail que le profil. + expected = {n: p for n, p in portail_of_role.items()} + portails_in_prof = {expected[row["role"]] for row in prof["roles"]} + if len(portails_in_prof) != 1: + errors.append(f"{name}: mélange de portails {sorted(portails_in_prof)}.") + else: + portail = portails_in_prof.pop() + if name != frappe.role_profile_name(portail): + errors.append(f"{name}: nom ≠ convention pour portail {portail!r}.") + + # Invariant 4 · le manifeste liste EXACTEMENT les portails du contrat. + meta_portails = {m["portail"] for m in manifest["profiles"]} + if meta_portails != contract_portails: + errors.append(f"manifest.profiles portails {sorted(meta_portails)} ≠ contrat {sorted(contract_portails)}.") + + # Invariant 5 · métadonnées fidèles (flag métier + comptes par profil). + nb_by_portail: dict[str, int] = {} + for prof in profiles: + p = portail_of_role[prof["roles"][0]["role"]] + nb_by_portail[p] = len(prof["roles"]) + for m in manifest["profiles"]: + p = m["portail"] + if m["metier"] != (p in portails_metier): + errors.append(f"profil {p!r}: flag metier incohérent avec portails_business.") + if m["role_profile"] != frappe.role_profile_name(p): + errors.append(f"profil {p!r}: role_profile ≠ convention de nommage.") + if m["nb_roles"] != nb_by_portail.get(p): + errors.append(f"profil {p!r}: nb_roles {m['nb_roles']} ≠ réel {nb_by_portail.get(p)}.") + + # Invariant 6 · cohérence des comptages agrégés du manifeste. + counts = manifest["counts"] + if counts["role_profiles"] != len(profiles): + errors.append("counts.role_profiles incohérent.") + if counts["roles_couverts"] != len(assigned): + errors.append("counts.roles_couverts incohérent avec les profils.") + nb_metier = sum(1 for m in manifest["profiles"] if m["metier"]) + if counts["portails_metier"] != nb_metier: + errors.append("counts.portails_metier incohérent.") + if counts["portails_techniques"] != len(profiles) - nb_metier: + errors.append("counts.portails_techniques incohérent.") + + # Invariant 7 · déterminisme : profils triés par portail, rôles triés par nom. + if [m["portail"] for m in manifest["profiles"]] != sorted(meta_portails): + errors.append("manifest.profiles non trié par portail (déterminisme).") + for prof in profiles: + role_seq = [row["role"] for row in prof["roles"]] + if role_seq != sorted(role_seq): + errors.append(f"{prof['name']}: Has Role non triés (déterminisme).") + + 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_profile.json"), bundle["role_profile"]) + _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) + + m = bundle["manifest"] + c = m["counts"] + print(f"✅ Role Profile générés dans {out}") + print( + f" role_profile.json : {c['role_profiles']} profils " + f"({c['portails_metier']} métier + {c['portails_techniques']} technique) " + f"· {c['roles_couverts']}/50 rôles couverts" + ) + for meta in m["profiles"]: + tag = "métier" if meta["metier"] else "technique" + print(f" - {meta['role_profile']} : {meta['nb_roles']} rôles ({tag})") + 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 + c = bundle["manifest"]["counts"] + print( + f"✅ Validation OK — {c['role_profiles']} Role Profile, " + f"{c['roles_couverts']}/50 rôles couverts (bijectif), " + f"schéma + invariants verts." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Générateur de Role Profile (bundles de rôles) depuis le contrat RBAC." + ) + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="génère role_profile.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()) diff --git a/05_deliverables_mvp/rbac/roleprofile_gen/tests/test_roleprofile_gen.py b/05_deliverables_mvp/rbac/roleprofile_gen/tests/test_roleprofile_gen.py new file mode 100644 index 0000000..a8ff3ea --- /dev/null +++ b/05_deliverables_mvp/rbac/roleprofile_gen/tests/test_roleprofile_gen.py @@ -0,0 +1,155 @@ +"""Tests du générateur de Role Profile (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 bundle valide contre roleprofile.schema.json (maison + oracle). + 2. Couverture BIJECTIVE : chaque rôle du contrat dans exactement un profil. + 3. Un profil par portail distinct du contrat ; nom = convention déterministe. + 4. Cohérence portail : tous les rôles d'un profil partagent le même portail. + 5. Manifeste fidèle : portails, flag métier (vs plateforme), comptes par profil. + 6. Anti-invention : aucun rôle hors contrat, aucun DocType custom (que du natif v15). + 7. Déterminisme : deux builds successifs bit-à-bit identiques + tris stables. + 8. Garde-fous : portail vide et profil vide → ValueError. + 9. 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, "..")) # roleprofile_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 profilelib 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, "roleprofile.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( + "roleprofile_gen", os.path.join(_GEN, "roleprofile_gen.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class RoleProfileGenTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.contract = _load(_CONTRACT) + cls.schema = _load(_SCHEMA) + cls.bundle = builder.build_bundle(cls.contract) + cls.profiles = cls.bundle["role_profile"] + cls.portail_of_role = { + r["erpnext_role_name"]: r["portail"] for r in cls.contract["roles"] + } + + # 1 · conformité au schéma (validateur maison) + def test_bundle_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_bundle_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_bijective_50_roles(self): + assigned = [row["role"] for p in self.profiles for row in p["roles"]] + self.assertEqual(len(assigned), 50) + self.assertEqual(len(assigned), len(set(assigned)), "Rôle dans plusieurs profils") + self.assertEqual(set(assigned), set(self.portail_of_role), "Couverture ≠ contrat") + + # 3 · un profil par portail distinct, nom = convention déterministe + def test_un_profil_par_portail(self): + portails = {r["portail"] for r in self.contract["roles"]} + noms = {p["name"] for p in self.profiles} + self.assertEqual(len(self.profiles), len(portails)) + self.assertEqual( + noms, {frappe.role_profile_name(p) for p in portails} + ) + for p in self.profiles: + self.assertEqual(p["name"], p["role_profile"]) + self.assertTrue(p["name"].startswith("OTO Portail ")) + + # 4 · cohérence portail : un profil ne mélange pas les portails + def test_coherence_portail(self): + for p in self.profiles: + portails = {self.portail_of_role[row["role"]] for row in p["roles"]} + self.assertEqual(len(portails), 1, f"{p['name']} mélange {portails}") + portail = portails.pop() + self.assertEqual(p["name"], frappe.role_profile_name(portail)) + + # 5 · manifeste fidèle (portails, flag métier, comptes) + def test_manifeste_fidele(self): + m = self.bundle["manifest"] + portails = {r["portail"] for r in self.contract["roles"]} + metier = set(self.contract["portails_business"]) + self.assertEqual({x["portail"] for x in m["profiles"]}, portails) + nb_reel = {} + for p in self.profiles: + portail = self.portail_of_role[p["roles"][0]["role"]] + nb_reel[portail] = len(p["roles"]) + for x in m["profiles"]: + self.assertEqual(x["metier"], x["portail"] in metier) + self.assertEqual(x["nb_roles"], nb_reel[x["portail"]]) + self.assertEqual(x["role_profile"], frappe.role_profile_name(x["portail"])) + self.assertEqual(m["counts"]["roles_couverts"], 50) + self.assertEqual(m["counts"]["role_profiles"], len(self.profiles)) + self.assertEqual(m["counts"]["portails_metier"], len(metier & portails)) + + # 6 · anti-invention : que du natif v15, aucun rôle hors contrat + def test_anti_invention(self): + for p in self.profiles: + self.assertEqual(p["doctype"], "Role Profile") + for row in p["roles"]: + self.assertEqual(row["doctype"], "Has Role") + self.assertIn(row["role"], self.portail_of_role, "Rôle hors contrat") + + # 7 · déterminisme + tris stables + def test_deterministe(self): + a = json.dumps(builder.build_bundle(_load(_CONTRACT))) + b = json.dumps(builder.build_bundle(_load(_CONTRACT))) + self.assertEqual(a, b, "Génération non déterministe") + portails_seq = [x["portail"] for x in self.bundle["manifest"]["profiles"]] + self.assertEqual(portails_seq, sorted(portails_seq), "profils non triés") + for p in self.profiles: + roles_seq = [row["role"] for row in p["roles"]] + self.assertEqual(roles_seq, sorted(roles_seq), f"{p['name']}: rôles non triés") + + # 8 · garde-fous + def test_portail_vide_rejete(self): + with self.assertRaises(ValueError): + frappe.role_profile_name("") + + def test_profil_vide_rejete(self): + with self.assertRaises(ValueError): + frappe.role_profile_fixture("ventes", []) + + # 9 · 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)