From d815c7ab6346af00ed65e96fb16ea055ffe32c93 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 03:03:12 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=202=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20fixtures=20ERPNext=20(RBAC=2050=20r=C3=B4les=20?= =?UTF-8?q?=E2=86=92=20Role=20+=20Custom=20DocPerm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transforme rbac_50_roles.json en fixtures Frappe/ERPNext v15 natives, prêtes à appliquer via bench (VPS · agent ERPNext). Réalise le prochain incrément annoncé au §7 de RBAC_50_ROLES_SPEC.md. Zéro pip, zéro VPS, sortie déterministe. - fixturelib/frappe.py : modèle natif (15 flags DocPerm, mapping 1:1 des verbes RBAC, if_owner⇔scope "own"). fixturelib/builder.py : bundle déterministe. - rbac_fixtures_gen.py : CLI build/validate (refuse d'écrire si invariant KO). - fixtures.schema.json : contrat de sortie (validateur maison Publiciste réutilisé). - 11 tests unittest : schéma+oracle, 50 rôles, séparation des pouvoirs, round-trip fidèle au contrat, déterminisme. Job CI rbac-fixtures-tests au gate. - Anti-invention #6 : 100% dérivé du contrat, flags non pilotés à 0, DocTypes custom signalés « à confirmer VPS ». Vérif : 11/11 verts + gate CI local vert (exit 0) + régression 60 tests OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 19 +- .../daily_reports/2026-07-30-session6.md | 86 +++++++++ .../rbac/RBAC_50_ROLES_SPEC.md | 16 +- .../rbac/fixtures_gen/.gitignore | 5 + .../rbac/fixtures_gen/README.md | 75 ++++++++ .../rbac/fixtures_gen/fixturelib/__init__.py | 10 + .../rbac/fixtures_gen/fixturelib/builder.py | 79 ++++++++ .../rbac/fixtures_gen/fixturelib/frappe.py | 126 +++++++++++++ .../rbac/fixtures_gen/fixtures.schema.json | 122 ++++++++++++ .../rbac/fixtures_gen/rbac_fixtures_gen.py | 178 ++++++++++++++++++ .../fixtures_gen/tests/test_fixtures_gen.py | 160 ++++++++++++++++ 11 files changed, 869 insertions(+), 7 deletions(-) create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session6.md create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/.gitignore create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/README.md create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/fixturelib/__init__.py create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/fixturelib/builder.py create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/fixturelib/frappe.py create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/fixtures.schema.json create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/rbac_fixtures_gen.py create mode 100644 05_deliverables_mvp/rbac/fixtures_gen/tests/test_fixtures_gen.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 64b1e9f..1cea576 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -90,10 +90,27 @@ jobs: - name: Tests générateur Faisabilité run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur de fixtures ERPNext (Sprint 2 · ERPNext Backend) : le contrat + # rbac_50_roles.json → fixtures Frappe `Role` + `Custom DocPerm`. Valide le + # bundle contre fixtures.schema.json + invariants (50 rôles, séparation des + # pouvoirs, round-trip fidèle au contrat). stdlib pur, aucune install pip. + # -------------------------------------------------------------------------- + rbac-fixtures-tests: + name: RBAC · fixtures ERPNext (Role + Custom DocPerm) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/rbac/fixtures_gen + steps: + - uses: actions/checkout@v4 + - name: Tests générateur de fixtures RBAC + 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] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-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-session6.md b/05_deliverables_mvp/daily_reports/2026-07-30-session6.md new file mode 100644 index 0000000..6093c8c --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session6.md @@ -0,0 +1,86 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 6) + +**Session** : `20260730_025643` + +## Tâche exécutée +**Sprint 2 · Livrable ERPNext Backend « Générateur de fixtures RBAC → Frappe »** +(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » · +`RBAC_50_ROLES_SPEC.md` §7 qui l'annonce comme prochain incrément · suggéré par +les daily reports sessions 4-5). + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, `GAP_ANALYSIS_SPRINT1.md`, + daily reports sessions 4-5, `rbac_50_roles.json` + `rbac.schema.json` + + `RBAC_50_ROLES_SPEC.md`, validateur maison Publiciste, patrons générateur + Faisabilité + CI. +- État Sprint 2 côté repo : RBAC contrat (s4), Publiciste parser+gen (s3), + Faisabilité générateur 4 volets (s5) livrés. Frontend/CRM dépendent des + layouts LIVE (VPS · hors périmètre). +- **Priorité évidente et déjà annoncée** (SPEC §7, reports s4/s5) : le + **générateur de fixtures ERPNext** `rbac_50_roles.json → Role + Custom + DocPerm`. Sur le chemin critique de l'application RBAC (S4), 100 % autorable + en-repo (zéro API, zéro VPS, zéro pip), branché sur le même patron que le + Publiciste/Faisabilité. + +## Réalisé — module `05_deliverables_mvp/rbac/fixtures_gen/` +- `fixturelib/frappe.py` — modèle **natif Frappe/ERPNext v15** : ordre des 15 + flags DocPerm, mapping **1:1** des 12 verbes RBAC → flags, `if_owner` dérivé de + `scope_donnees == "own"`, constructeurs `role_fixture()` / `docperm_fixture()`. + Aucun chiffre métier (contrainte #1 : ERPNext natif, aucun modèle externe). +- `fixturelib/builder.py` — `build_bundle()` **déterministe** (tri stable rôles + par nom Frappe, DocPerms par (role, doctype, permlevel)), collecte des DocTypes + `custom` à confirmer, manifeste de traçabilité. +- `rbac_fixtures_gen.py` — CLI `build` (écrit `role.json` / `custom_docperm.json` + / `MANIFEST.json`) et `validate`. La génération **refuse d'écrire** si un + invariant casse (schéma, 50 rôles, unicité, séparation des pouvoirs). +- `fixtures.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté par + le **validateur maison Publiciste**, zéro pip). +- `tests/test_fixtures_gen.py` — **11 tests `unittest` (stdlib pur)** : schéma + (maison + oracle `jsonschema` si présent), 50 rôles uniques, flags non pilotés + à 0, `if_owner`⇔scope, séparation des pouvoirs, **round-trip fidèle au + contrat** (aucune permission inventée ni perdue), déterminisme bit-à-bit. +- **CI** : job `rbac-fixtures-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). +- `RBAC_50_ROLES_SPEC.md §7` mis à jour (générateur livré). + +## Anti-invention (#6) appliqué +- 100 % des données proviennent du contrat `rbac_50_roles.json` déjà validé ; + seuls ajouts = flags DocPerm natifs à **0** (défaut sûr) + méta de traçabilité. +- Verbes non pilotés (`select`/`amend`/`import`) forcés à 0 et testés. +- Verbe hors contrat → `ValueError` explicite (garde-fou anti-contrat corrompu). +- DocTypes DTP `custom` **jamais supposés existants** : listés dans le manifeste + « à créer + confirmer VPS » avant import (SPEC §6/§7). +- Séparation des pouvoirs : `set_user_permissions` réservé au RBAC Admin, + vérifié par test **et** par le CLI (échec si un autre rôle le porte). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **11/11 tests verts** (schéma maison + oracle `jsonschema` → concordance + draft-07 confirmée). Génération réelle : 50 rôles, **116 Custom DocPerm** sur + 45 DocTypes, 4 DocTypes `custom` signalés. +- **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/23 Publiciste + 10/10 RBAC + 16/16 Faisabilité toujours + verts (réutilisation du validateur sans effet de bord). **Total 60 tests.** + +## Non fait (hors périmètre worker · VPS) +- Application des fixtures (`bench migrate` / `import-fixtures`) sur + `erpnext-backend-1` → agent ERPNext Backend. +- Création des DocTypes DTP `custom` + mapping `scope_donnees` → `User Permission` + par utilisateur → VPS. +- `permlevel > 0` (segmentation par niveau de champ) : non spécifié par le + contrat RBAC → non couvert (anti-invention #6). + +## Prochaine tâche suggérée +- ERPNext S2 : générateur des fixtures `User Permission` **par entité** (WAF/WA + SRL/AC…) depuis `scope_donnees` (même patron, autorable en-repo partiellement). +- Ou Faisabilité S3 : `40_llm_outputs/` + rapports bancables FR/EN/ES. +- Ou Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker. + +--- + +**Auto-score 4Big du livrable Générateur de fixtures RBAC : 96/100.** Réserve +−4 : application `bench` + DocTypes `custom` + `User Permission` = côté VPS +(agent ERPNext, hors périmètre) ; `permlevel > 0` non couvert (anti-invention +#6). Validé statiquement en-repo (11 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 9239869..ccd5be4 100644 --- a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md +++ b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md @@ -124,15 +124,19 @@ du code sans vérifier l'existence courante »). Ce worker n'écrit **jamais** sur le VPS. L'agent ERPNext Backend appliquera la cible côté serveur (`erpnext-backend-1`) : -1. Générer les fixtures `Role` + `Custom DocPerm` depuis `rbac_50_roles.json`. -2. `bench --site frontend migrate` pour installer les rôles. -3. Créer les DocTypes DTP `custom` manquants (§6) **après** confirmation. +1. Générer les fixtures `Role` + `Custom DocPerm` depuis `rbac_50_roles.json` + — **livré** : [`fixtures_gen/`](fixtures_gen/README.md) (`python3 rbac_fixtures_gen.py build`). +2. Créer les DocTypes DTP `custom` manquants (§6) **après** confirmation + (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. 5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big. -> Un **générateur de fixtures** `rbac_50_roles.json → fixtures/` est le prochain -> incrément (branchable sur le même patron que le Publiciste) ; il reste autorable -> en-repo sans toucher au VPS. +> ✅ 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. ## 8. Vérification (en-repo, sans toucher au VPS) diff --git a/05_deliverables_mvp/rbac/fixtures_gen/.gitignore b/05_deliverables_mvp/rbac/fixtures_gen/.gitignore new file mode 100644 index 0000000..a449a97 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/.gitignore @@ -0,0 +1,5 @@ +# Artefacts de génération locale (jamais commités — produits à la demande par +# `python3 rbac_fixtures_gen.py build`, re-générables en CI). +__pycache__/ +*.pyc +out/ diff --git a/05_deliverables_mvp/rbac/fixtures_gen/README.md b/05_deliverables_mvp/rbac/fixtures_gen/README.md new file mode 100644 index 0000000..92bbe26 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/README.md @@ -0,0 +1,75 @@ +# Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe + +**Sprint 2 · agent ERPNext Backend.** Transforme le contrat +[`../rbac_50_roles.json`](../rbac_50_roles.json) (validé par +[`../rbac.schema.json`](../rbac.schema.json)) en **fixtures Frappe/ERPNext v15 +natives**, prêtes à appliquer sur le VPS par `bench`. Réalise le « prochain +incrément » annoncé 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 les +> fichiers en-repo ; l'application réelle (`bench migrate`) reste côté serveur. + +## Ce qui est généré (`out/`, non commité) + +| Fichier | DocType Frappe | Rôle | +|---|---|---| +| `role.json` | `Role` | 50 rôles desk préfixés `OTO ` (anti-collision natif). | +| `custom_docperm.json` | `Custom DocPerm` | Permissions par DocType, **sans** modifier le DocType (réversible, ne casse pas les updates d'app). | +| `MANIFEST.json` | — | Traçabilité (comptes, version source) + **DocTypes DTP `custom` à créer/confirmer VPS**. | + +## Utilisation + +```bash +# Génère out/role.json, out/custom_docperm.json, out/MANIFEST.json +python3 rbac_fixtures_gen.py build # [-o DOSSIER] + +# Valide le bundle (schéma + invariants) sans rien écrire +python3 rbac_fixtures_gen.py validate + +# Tests (stdlib pur, zéro pip) +python3 -m unittest discover -s tests -v +``` + +## Mapping RBAC → Frappe (aucune invention · #6) + +- Chaque `permissions_cibles[].actions` du contrat lève **1:1** le flag DocPerm + natif correspondant (`read`/`write`/`create`/`delete`/`submit`/`cancel`/ + `report`/`export`/`print`/`email`/`share`/`set_user_permissions`). Les flags + non pilotés (`select`/`amend`/`import`) restent à **0** (défaut sûr). +- `scope_donnees == "own"` → `if_owner = 1` (restriction Frappe native). Les + portées `equipe`/`entite`/`groupe` relèvent des **User Permission** par + utilisateur → appliquées sur le VPS (SPEC §7), **hors périmètre worker**. +- `permlevel = 0` (le contrat ne segmente pas par niveau de champ). +- Sortie **déterministe** (tri stable, aucun horodatage) → diffable, re-générable + bit-à-bit en CI. + +## Séparation des pouvoirs (défense en profondeur) + +Le flag `set_user_permissions` n'est émis que pour le rôle **RBAC Admin** +(`famille = plateforme`, `scope = groupe`) — vérifié par test et par le CLI +(`validate` échoue si un autre rôle le porte). Aucune élévation de privilège +possible via les fixtures. + +## Application sur VPS (agent ERPNext Backend · hors périmètre worker) + +1. Créer d'abord les DocTypes DTP `custom` listés dans `MANIFEST.custom_doctypes_a_confirmer` + (`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. +5. 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 ; round-trip fidèle au contrat, déterminisme, + séparation des pouvoirs). +- Job CI dédié `rbac-fixtures-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`, + Gitea Actions uniquement · #2). + +## Auto-score 4Big du livrable : **96/100** + +_Réserve −4_ : application des fixtures + création des DocTypes `custom` + +mapping `User Permission` = côté VPS (agent ERPNext, hors périmètre worker) ; +`permlevel > 0` non couvert (le contrat RBAC ne le spécifie pas — anti-invention +#6). Validé statiquement en-repo (11 tests verts + schéma conforme + gate CI). diff --git a/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/__init__.py b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/__init__.py new file mode 100644 index 0000000..732ddc9 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/__init__.py @@ -0,0 +1,10 @@ +"""Bibliothèque de génération de fixtures ERPNext depuis le contrat RBAC. + +Modules : + - frappe : modèle natif Frappe (Role / Custom DocPerm) + mapping des verbes. + - builder : transforme rbac_50_roles.json → bundle de fixtures déterministe. +""" + +from . import builder, frappe + +__all__ = ["builder", "frappe"] diff --git a/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/builder.py b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/builder.py new file mode 100644 index 0000000..99ef303 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/builder.py @@ -0,0 +1,79 @@ +"""Construction des fixtures ERPNext 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 contenant les deux +fichiers de fixtures Frappe (`role`, `custom_docperm`) + un manifeste de +traçabilité (contrainte #6 : tout provient du contrat, rien n'est inventé). + +Déterminisme : tri stable (rôles par nom Frappe, DocPerms par (role, doctype, +permlevel)) → la sortie est reproductible bit-à-bit à contrat constant, donc +diffable et re-générable en CI sans horodatage volatil. +""" + +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_bundle(contract: dict) -> dict[str, Any]: + """Transforme le contrat RBAC en bundle de fixtures Frappe déterministe.""" + roles = _sorted_roles(contract) + + # --- 1. Fixtures Role (unicité garantie par le contrat, re-vérifiée) ------ + role_fixtures: list[dict] = [] + seen_roles: set[str] = set() + for r in roles: + name = r["erpnext_role_name"] + if name in seen_roles: + raise ValueError(f"Rôle Frappe dupliqué dans le contrat : {name!r}") + seen_roles.add(name) + role_fixtures.append(frappe.role_fixture(name)) + + # --- 2. Fixtures Custom DocPerm ------------------------------------------ + docperms: list[dict] = [] + custom_doctypes: set[str] = set() + for r in roles: + role_name = r["erpnext_role_name"] + if_owner = r["scope_donnees"] == frappe.SCOPE_IF_OWNER + # Tri des DocTypes cibles pour un ordre stable indépendant du contrat. + for perm in sorted(r["permissions_cibles"], key=lambda p: p["doctype"]): + doctype = perm["doctype"] + if perm.get("custom") is True: + custom_doctypes.add(doctype) + docperms.append( + frappe.docperm_fixture( + doctype, + role_name, + perm["actions"], + if_owner=if_owner, + ) + ) + + docperms.sort(key=lambda d: (d["role"], d["parent"], d["permlevel"])) + + # --- 3. Manifeste de traçabilité ----------------------------------------- + manifest = { + "generated_from": "rbac_50_roles.json", + "source_version": contract["version"], + "cible_rbac_roles": contract["cible_rbac_roles"], + "counts": { + "role": len(role_fixtures), + "custom_docperm": len(docperms), + "doctypes_uniques": len({d["parent"] for d in docperms}), + }, + # DocTypes DTP non natifs : à CRÉER + confirmer sur le VPS avant import + # des DocPerms qui les ciblent (SPEC §6/§7). Jamais supposés existants. + "custom_doctypes_a_confirmer": sorted(custom_doctypes), + } + + return { + "manifest": manifest, + "role": role_fixtures, + "custom_docperm": docperms, + } diff --git a/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/frappe.py b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/frappe.py new file mode 100644 index 0000000..a951bc1 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/fixturelib/frappe.py @@ -0,0 +1,126 @@ +"""Modèle Frappe/ERPNext v15 : mapping RBAC → fixtures `Role` + `Custom DocPerm`. + +Sépare la CONNAISSANCE FRAPPE (noms de champs, ordre des colonnes de permission, +valeurs par défaut) de la logique de construction (`builder.py`). Aucun chiffre +métier ici — uniquement la structure native ERPNext (contrainte #1 « ERPNext +natif = priorité absolue » : on n'invente aucun modèle de permission externe). + +Références Frappe : + - DocType `Role` → un rôle desk (préfixe « OTO » déjà porté par le + contrat `rbac_50_roles.json`, anti-collision natif). + - DocType `Custom DocPerm`→ personnalise les permissions d'un DocType existant + SANS modifier le DocType lui-même (idéal pour appliquer par fixtures + + `bench migrate`, réversible, ne casse pas les mises à jour d'app). + +Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables. +""" + +from __future__ import annotations + +from typing import Any + +# --------------------------------------------------------------------------- # +# Colonnes de permission d'un DocPerm Frappe, dans l'ordre natif du DocType. +# Toutes défaut 0 ; on ne lève à 1 que les verbes déclarés dans le contrat RBAC. +# --------------------------------------------------------------------------- # +DOCPERM_FLAGS: tuple[str, ...] = ( + "select", + "read", + "write", + "create", + "delete", + "submit", + "cancel", + "amend", + "report", + "export", + "import", + "print", + "email", + "share", + "set_user_permissions", +) + +# Verbes que le contrat RBAC (`permissions_cibles[].actions`) peut émettre. +# TOUS correspondent 1:1 à un flag DocPerm natif ci-dessus → aucune traduction +# arbitraire, aucune invention. Un verbe hors de cet ensemble = erreur de contrat. +RBAC_ACTIONS: frozenset[str] = frozenset( + { + "read", + "write", + "create", + "delete", + "submit", + "cancel", + "report", + "export", + "print", + "email", + "share", + "set_user_permissions", + } +) + +# Verbes DocPerm jamais pilotés par le contrat RBAC (restent à 0) — documentés +# pour prouver qu'aucun flag n'est activé « en douce ». +NON_PILOTES: frozenset[str] = frozenset(set(DOCPERM_FLAGS) - RBAC_ACTIONS) + +# permlevel par défaut (0 = champs de base). Le contrat RBAC ne segmente pas par +# niveau de champ → on reste au niveau 0 (pas d'invention de permlevel). +DEFAULT_PERMLEVEL = 0 + +# `scope_donnees == "own"` → restriction Frappe native `if_owner` au niveau +# DocPerm. Les autres portées (equipe/entite/groupe) relèvent des `User +# Permission` par utilisateur (hors périmètre worker · appliqué VPS §7 du SPEC). +SCOPE_IF_OWNER = "own" + + +def role_fixture(erpnext_role_name: str) -> dict[str, Any]: + """Construit un enregistrement de fixture `Role` Frappe minimal mais valide. + + `bench import-fixtures` complète les autres champs par leurs défauts DocType. + On force `desk_access=1` (rôles back-office ERPNext) et `disabled=0`. + """ + return { + "doctype": "Role", + "name": erpnext_role_name, + "role_name": erpnext_role_name, + "desk_access": 1, + "disabled": 0, + "two_factor_auth": 0, + "restrict_to_domain": None, + } + + +def docperm_fixture( + doctype: str, + role_name: str, + actions: list[str], + *, + if_owner: bool, + permlevel: int = DEFAULT_PERMLEVEL, +) -> dict[str, Any]: + """Construit un enregistrement `Custom DocPerm` pour (DocType, Role). + + Les flags absents de `actions` restent à 0 (défaut sûr). `name` est + volontairement omis → Frappe l'auto-nomme à l'import (hash déterministe côté + serveur), ce qui évite d'inventer un identifiant ici. + """ + unknown = set(actions) - RBAC_ACTIONS + if unknown: + raise ValueError( + f"Verbe(s) RBAC inconnu(s) pour {doctype}/{role_name} : {sorted(unknown)}" + ) + rec: dict[str, Any] = { + "doctype": "Custom DocPerm", + "parent": doctype, + "parenttype": "DocType", + "parentfield": "permissions", + "role": role_name, + "permlevel": permlevel, + "if_owner": 1 if if_owner else 0, + } + wanted = set(actions) + for flag in DOCPERM_FLAGS: + rec[flag] = 1 if flag in wanted else 0 + return rec diff --git a/05_deliverables_mvp/rbac/fixtures_gen/fixtures.schema.json b/05_deliverables_mvp/rbac/fixtures_gen/fixtures.schema.json new file mode 100644 index 0000000..038baa3 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/fixtures.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/rbac/fixtures.schema.json", + "title": "Bundle de fixtures ERPNext généré depuis rbac_50_roles.json", + "description": "Contrat de sortie du générateur rbac_fixtures_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-fixtures-tests.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "role", "custom_docperm"], + "properties": { + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated_from", + "source_version", + "cible_rbac_roles", + "counts", + "custom_doctypes_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": ["role", "custom_docperm", "doctypes_uniques"], + "properties": { + "role": { "type": "integer", "minimum": 50, "maximum": 50 }, + "custom_docperm": { "type": "integer", "minimum": 1 }, + "doctypes_uniques": { "type": "integer", "minimum": 1 } + } + }, + "custom_doctypes_a_confirmer": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "role": { + "type": "array", + "minItems": 50, + "maxItems": 50, + "items": { "$ref": "#/definitions/role" } + }, + "custom_docperm": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/custom_docperm" } + } + }, + "definitions": { + "flag": { "type": "integer", "enum": [0, 1] }, + "role": { + "type": "object", + "additionalProperties": false, + "required": ["doctype", "name", "role_name", "desk_access", "disabled"], + "properties": { + "doctype": { "type": "string", "const": "Role" }, + "name": { "type": "string", "pattern": "^OTO " }, + "role_name": { "type": "string", "pattern": "^OTO " }, + "desk_access": { "$ref": "#/definitions/flag" }, + "disabled": { "$ref": "#/definitions/flag" }, + "two_factor_auth": { "$ref": "#/definitions/flag" }, + "restrict_to_domain": { "type": ["string", "null"] } + } + }, + "custom_docperm": { + "type": "object", + "additionalProperties": false, + "required": [ + "doctype", + "parent", + "parenttype", + "parentfield", + "role", + "permlevel", + "if_owner", + "select", + "read", + "write", + "create", + "delete", + "submit", + "cancel", + "amend", + "report", + "export", + "import", + "print", + "email", + "share", + "set_user_permissions" + ], + "properties": { + "doctype": { "type": "string", "const": "Custom DocPerm" }, + "parent": { "type": "string", "minLength": 1 }, + "parenttype": { "type": "string", "const": "DocType" }, + "parentfield": { "type": "string", "const": "permissions" }, + "role": { "type": "string", "pattern": "^OTO " }, + "permlevel": { "type": "integer", "minimum": 0 }, + "if_owner": { "$ref": "#/definitions/flag" }, + "select": { "$ref": "#/definitions/flag" }, + "read": { "$ref": "#/definitions/flag" }, + "write": { "$ref": "#/definitions/flag" }, + "create": { "$ref": "#/definitions/flag" }, + "delete": { "$ref": "#/definitions/flag" }, + "submit": { "$ref": "#/definitions/flag" }, + "cancel": { "$ref": "#/definitions/flag" }, + "amend": { "$ref": "#/definitions/flag" }, + "report": { "$ref": "#/definitions/flag" }, + "export": { "$ref": "#/definitions/flag" }, + "import": { "$ref": "#/definitions/flag" }, + "print": { "$ref": "#/definitions/flag" }, + "email": { "$ref": "#/definitions/flag" }, + "share": { "$ref": "#/definitions/flag" }, + "set_user_permissions": { "$ref": "#/definitions/flag" } + } + } + } +} diff --git a/05_deliverables_mvp/rbac/fixtures_gen/rbac_fixtures_gen.py b/05_deliverables_mvp/rbac/fixtures_gen/rbac_fixtures_gen.py new file mode 100644 index 0000000..b718440 --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/rbac_fixtures_gen.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe fixtures (Sprint 2). + +Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit +JAMAIS sur le VPS — il produit en-repo les fichiers de fixtures que l'agent +appliquera côté serveur via `bench --site frontend migrate` / `import-fixtures`. + +Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en : + - role.json → fixtures DocType `Role` (50 rôles préfixés « OTO ») + - custom_docperm.json → fixtures `Custom DocPerm` (permissions par DocType) + - MANIFEST.json → traçabilité + DocTypes DTP `custom` à confirmer VPS + +Sous-commandes : + build [-o OUT] → écrit role.json / custom_docperm.json / MANIFEST.json + validate [-o OUT] → (re)génère en mémoire, valide vs fixtures.schema.json, + vérifie les invariants (50 rôles, unicité, séparation + des pouvoirs) — 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 ; les seuls +ajouts sont des flags DocPerm natifs à 0 (défaut sûr) et des méta de traçabilité. +""" + +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 fixturelib import builder # 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, "fixtures.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 fixtures.schema.json + invariants métier RBAC.""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + manifest = bundle["manifest"] + roles = bundle["role"] + docperms = bundle["custom_docperm"] + + # Invariant 1 · cible non-négociable « RBAC 50 rôles ». + if manifest["cible_rbac_roles"] != len(roles): + errors.append( + f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ role fixtures={len(roles)}" + ) + + # Invariant 2 · unicité des noms de rôle Frappe (anti-collision). + names = [r["role_name"] for r in roles] + if len(names) != len(set(names)): + errors.append("Noms de rôle Frappe dupliqués dans les fixtures Role.") + + # Invariant 3 · unicité (role, DocType, permlevel) côté DocPerm. + keys = [(d["role"], d["parent"], d["permlevel"]) for d in docperms] + if len(keys) != len(set(keys)): + errors.append("DocPerm dupliqué pour un même (role, DocType, permlevel).") + + # Invariant 4 · séparation des pouvoirs (#6) : `set_user_permissions` + # réservé au seul RBAC Admin (aucune élévation de privilège fixée en dur). + contract = _load(_CONTRACT_PATH) + admin_roles = { + r["erpnext_role_name"] + for r in contract["roles"] + if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe" + } + for d in docperms: + if d["set_user_permissions"] == 1 and d["role"] not in admin_roles: + errors.append( + f"Élévation de privilège : {d['role']} porte set_user_permissions " + f"sur {d['parent']} (réservé au RBAC Admin)." + ) + + # Invariant 5 · cohérence de comptage du manifeste. + if manifest["counts"]["role"] != len(roles): + errors.append("counts.role incohérent avec le nombre de fixtures Role.") + if manifest["counts"]["custom_docperm"] != len(docperms): + errors.append("counts.custom_docperm incohérent avec les DocPerm.") + + 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.json"), bundle["role"]) + _write_json(os.path.join(out, "custom_docperm.json"), bundle["custom_docperm"]) + _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) + + m = bundle["manifest"] + print(f"✅ Fixtures générées dans {out}") + print(f" role.json : {m['counts']['role']} rôles") + print( + f" custom_docperm.json : {m['counts']['custom_docperm']} DocPerm " + f"sur {m['counts']['doctypes_uniques']} DocTypes" + ) + if m["custom_doctypes_a_confirmer"]: + print( + " ⚠ DocTypes DTP `custom` à créer + confirmer VPS avant import : " + + ", ".join(m["custom_doctypes_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']['role']} rôles, " + f"{m['counts']['custom_docperm']} DocPerm, schéma + invariants verts." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Générateur de fixtures ERPNext depuis le contrat RBAC.") + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="génère role.json / custom_docperm.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/fixtures_gen/tests/test_fixtures_gen.py b/05_deliverables_mvp/rbac/fixtures_gen/tests/test_fixtures_gen.py new file mode 100644 index 0000000..450564e --- /dev/null +++ b/05_deliverables_mvp/rbac/fixtures_gen/tests/test_fixtures_gen.py @@ -0,0 +1,160 @@ +"""Tests du générateur de fixtures ERPNext (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 fixtures.schema.json (maison + oracle). + 2. Exactement 50 fixtures Role, noms Frappe uniques préfixés « OTO ». + 3. Chaque DocPerm ne lève que des flags déclarés dans le contrat ; les verbes + non pilotés (select/amend/import) restent à 0 (défaut sûr). + 4. `if_owner` ⇔ scope_donnees == "own" (fidélité au contrat). + 5. Séparation des pouvoirs : set_user_permissions réservé au RBAC Admin. + 6. Round-trip : chaque DocPerm renvoie vers une permission réelle du contrat + (aucune permission inventée, aucune perdue). + 7. Déterminisme : deux builds successifs sont bit-à-bit identiques. + 8. Les DocTypes `custom` du contrat sont tous signalés dans le manifeste. +""" + +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, "..")) # fixtures_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 fixturelib 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, "fixtures.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( + "rbac_fixtures_gen", os.path.join(_GEN, "rbac_fixtures_gen.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class FixturesGenTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.contract = _load(_CONTRACT) + cls.schema = _load(_SCHEMA) + cls.bundle = builder.build_bundle(cls.contract) + + # 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 · exactement 50 rôles, noms uniques préfixés OTO + def test_50_roles_uniques(self): + roles = self.bundle["role"] + self.assertEqual(len(roles), 50) + names = [r["role_name"] for r in roles] + self.assertEqual(len(names), len(set(names)), "Noms de rôle dupliqués") + for r in roles: + self.assertTrue(r["role_name"].startswith("OTO ")) + self.assertEqual(r["name"], r["role_name"]) + self.assertEqual(r["desk_access"], 1) + + # 3 · verbes non pilotés toujours à 0 ; flags levés ⊆ RBAC_ACTIONS + def test_flags_non_pilotes_a_zero(self): + for d in self.bundle["custom_docperm"]: + for flag in frappe.NON_PILOTES: + self.assertEqual(d[flag], 0, f"{flag} levé indûment sur {d['parent']}") + leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1} + self.assertTrue( + leves <= frappe.RBAC_ACTIONS, + f"Flag hors contrat levé : {leves - frappe.RBAC_ACTIONS}", + ) + + # 4 · if_owner ⇔ scope "own" + def test_if_owner_reflete_scope_own(self): + scope_by_role = {r["erpnext_role_name"]: r["scope_donnees"] for r in self.contract["roles"]} + for d in self.bundle["custom_docperm"]: + expected = 1 if scope_by_role[d["role"]] == "own" else 0 + self.assertEqual(d["if_owner"], expected, f"if_owner incohérent pour {d['role']}") + + # 5 · séparation des pouvoirs + def test_set_user_permissions_reserve_admin(self): + admin = { + r["erpnext_role_name"] + for r in self.contract["roles"] + if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe" + } + for d in self.bundle["custom_docperm"]: + if d["set_user_permissions"] == 1: + self.assertIn(d["role"], admin, f"Élévation de privilège : {d['role']}") + + # 6 · round-trip : chaque DocPerm ↔ une permission réelle du contrat + def test_roundtrip_fidele_au_contrat(self): + # (role, doctype) → set(actions) attendu depuis le contrat + expected = {} + for r in self.contract["roles"]: + for p in r["permissions_cibles"]: + expected[(r["erpnext_role_name"], p["doctype"])] = set(p["actions"]) + seen = set() + for d in self.bundle["custom_docperm"]: + key = (d["role"], d["parent"]) + self.assertIn(key, expected, f"DocPerm inventé : {key}") + leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1} + self.assertEqual(leves, expected[key], f"Actions divergentes pour {key}") + seen.add(key) + self.assertEqual(seen, set(expected), "Des permissions du contrat sont perdues") + + # 7 · déterminisme bit-à-bit + def test_deterministe(self): + a = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False) + b = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False) + self.assertEqual(a, b, "Génération non déterministe") + + # 8 · DocTypes custom tous signalés dans le manifeste + def test_manifeste_signale_doctypes_custom(self): + contract_custom = sorted( + { + p["doctype"] + for r in self.contract["roles"] + for p in r["permissions_cibles"] + if p.get("custom") is True + } + ) + self.assertEqual( + self.bundle["manifest"]["custom_doctypes_a_confirmer"], contract_custom + ) + + # 9 · verbe inconnu → erreur explicite (garde-fou anti-contrat corrompu) + def test_verbe_inconnu_rejete(self): + with self.assertRaises(ValueError): + frappe.docperm_fixture("Lead", "OTO X", ["read", "teleport"], if_owner=False) + + # 10 · le CLI validate/build fonctionne 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)