[DTP-Worker] Sprint 2 · Générateur plan User Permission (RBAC row-level scope_donnees → Frappe)
Complète le pipeline RBAC (Role + Custom DocPerm déjà livrés) par la dimension row-level. Mapping natif ERPNext v15 des 4 scope_donnees : - entite → User Permission allow=Company (28 templates, user=sentinelle) - own → if_owner (déjà posé par fixtures_gen) - groupe → aucune restriction (vue consolidée) - equipe → pas de dimension native → signalé VPS (jamais mappé, #6) Module userperm_gen/ : permlib/{frappe,builder}, CLI build/validate (refuse d'écrire si invariant KO), userperm.schema.json (validateur maison, zéro pip), 12 tests unittest, README. Job CI rbac-userperm-tests ajouté au gate. Docs SPEC §7 + fixtures_gen README cousues. Régression 72 tests verts, gate OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-1
@@ -107,10 +107,28 @@ jobs:
|
|||||||
- name: Tests générateur de fixtures RBAC
|
- name: Tests générateur de fixtures RBAC
|
||||||
run: python3 -m unittest discover -s tests -v
|
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:
|
gate:
|
||||||
name: Gate qualité (agrégat)
|
name: Gate qualité (agrégat)
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Résultat
|
- name: Résultat
|
||||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -130,13 +130,24 @@ cible côté serveur (`erpnext-backend-1`) :
|
|||||||
(listés dans `MANIFEST.custom_doctypes_a_confirmer`).
|
(listés dans `MANIFEST.custom_doctypes_a_confirmer`).
|
||||||
3. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO
|
3. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO
|
||||||
(`hooks.py`), puis `bench --site frontend migrate`.
|
(`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.
|
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é**
|
> ✅ 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
|
> 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 +
|
> 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.
|
> 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)
|
## 8. Vérification (en-repo, sans toucher au VPS)
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ possible via les fixtures.
|
|||||||
(`Faisabilité`, `Publiciste Log`, `CONFOTUR Application`, `API Access`) **après confirmation d'existence**.
|
(`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`.
|
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`).
|
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.
|
5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
|
||||||
|
|
||||||
## Vérification en-repo
|
## Vérification en-repo
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Générateur de plan `User Permission` ERPNext depuis le contrat RBAC 50 rôles.
|
||||||
|
|
||||||
|
Complète le générateur de fixtures `Role` + `Custom DocPerm` (`../fixtures_gen`)
|
||||||
|
en couvrant la dimension ROW-LEVEL (`scope_donnees`) que les DocPerm ne portent
|
||||||
|
pas à eux seuls. Voir `frappe.py` pour la connaissance Frappe native et
|
||||||
|
`builder.py` pour la construction déterministe.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Construction du plan `User Permission` depuis `rbac_50_roles.json`.
|
||||||
|
|
||||||
|
Entrée : le contrat RBAC 50 rôles (déjà validé par `rbac.schema.json` dans le
|
||||||
|
job CI `rbac-tests`). Sortie : un « plan » déterministe qui, pour CHAQUE rôle,
|
||||||
|
indique le mécanisme Frappe d'enforcement row-level de son `scope_donnees` et,
|
||||||
|
le cas échéant, un template `User Permission` (allow=Company) à matérialiser par
|
||||||
|
utilisateur côté VPS.
|
||||||
|
|
||||||
|
Déterminisme : tri stable des entrées par nom de rôle Frappe → sortie
|
||||||
|
reproductible bit-à-bit à contrat constant (diffable, re-générable en CI).
|
||||||
|
|
||||||
|
Anti-invention (#6) : 100 % des données proviennent du contrat (nom du rôle,
|
||||||
|
scope, entité). Aucun utilisateur n'est inventé (sentinelle `USER_PLACEHOLDER`).
|
||||||
|
Les rôles `equipe` (pas de dimension row-level native) ne reçoivent AUCUN
|
||||||
|
template et sont signalés « à confirmer VPS » plutôt que mappés arbitrairement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_roles(contract: dict) -> list[dict]:
|
||||||
|
return sorted(contract["roles"], key=lambda r: r["erpnext_role_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(contract: dict) -> dict[str, Any]:
|
||||||
|
"""Transforme le contrat RBAC en plan `User Permission` déterministe."""
|
||||||
|
roles = _sorted_roles(contract)
|
||||||
|
|
||||||
|
plan: list[dict] = []
|
||||||
|
companies: set[str] = set() # for_value distincts (portée entite)
|
||||||
|
equipe_roles: list[str] = [] # rôles equipe → à confirmer VPS
|
||||||
|
by_mechanism: dict[str, int] = {m: 0 for m in frappe.SCOPE_MECHANISM.values()}
|
||||||
|
|
||||||
|
for r in roles:
|
||||||
|
role_name = r["erpnext_role_name"]
|
||||||
|
scope = r["scope_donnees"]
|
||||||
|
entite = r["entite_principale"]
|
||||||
|
mechanism = frappe.mechanism_for(scope)
|
||||||
|
by_mechanism[mechanism] += 1
|
||||||
|
|
||||||
|
template = None
|
||||||
|
if mechanism == frappe.MECH_WITH_TEMPLATE:
|
||||||
|
# Lève ValueError si le contrat couple `entite` à un périmètre
|
||||||
|
# consolidé (« Groupe ») — garde-fou anti-contrat corrompu.
|
||||||
|
template = frappe.user_permission_template(entite)
|
||||||
|
companies.add(entite)
|
||||||
|
elif scope == "equipe":
|
||||||
|
equipe_roles.append(role_name)
|
||||||
|
|
||||||
|
plan.append(
|
||||||
|
{
|
||||||
|
"erpnext_role_name": role_name,
|
||||||
|
"scope_donnees": scope,
|
||||||
|
"entite_principale": entite,
|
||||||
|
"mechanism": mechanism,
|
||||||
|
"user_permission_template": template,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"generated_from": "rbac_50_roles.json",
|
||||||
|
"source_version": contract["version"],
|
||||||
|
"cible_rbac_roles": contract["cible_rbac_roles"],
|
||||||
|
"counts": {
|
||||||
|
"plan_entries": len(plan),
|
||||||
|
"user_permission_templates": by_mechanism[frappe.MECH_WITH_TEMPLATE],
|
||||||
|
"by_mechanism": by_mechanism,
|
||||||
|
},
|
||||||
|
# Companies ERPNext à CRÉER/confirmer sur le VPS avant d'appliquer les
|
||||||
|
# User Permission (les noms viennent de entite_principale du contrat).
|
||||||
|
"companies_a_confirmer": sorted(companies),
|
||||||
|
# Rôles `equipe` : aucune dimension row-level native → l'agent ERPNext
|
||||||
|
# doit décider du mécanisme (champ custom + User Permission, ou
|
||||||
|
# hiérarchie Employee.reports_to). Jamais mappé en douce ici (#6).
|
||||||
|
"roles_scope_equipe_a_confirmer": sorted(equipe_roles),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"manifest": manifest, "plan": plan}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Modèle Frappe/ERPNext v15 : mapping `scope_donnees` RBAC → `User Permission`.
|
||||||
|
|
||||||
|
Pourquoi ce module en plus de `../fixtures_gen`
|
||||||
|
------------------------------------------------
|
||||||
|
Les `Custom DocPerm` (générés par `fixtures_gen`) répondent à la question
|
||||||
|
« QUELS verbes sur QUEL DocType » — mais PAS « SUR QUELLES LIGNES ». La portée
|
||||||
|
row-level du contrat RBAC (`scope_donnees ∈ {own, equipe, entite, groupe}`) est
|
||||||
|
enforcée côté Frappe par deux mécanismes NATIFS distincts :
|
||||||
|
|
||||||
|
- `own` → flag `if_owner` du DocPerm (déjà posé par `fixtures_gen`, ici on
|
||||||
|
ne fait que le constater — aucune `User Permission` requise).
|
||||||
|
- `entite` → un enregistrement `User Permission` (allow=Company) par
|
||||||
|
utilisateur, restreignant l'accès aux docs de SA Company.
|
||||||
|
- `groupe` → AUCUNE restriction (vue consolidée transversale) → pas de
|
||||||
|
`User Permission`.
|
||||||
|
- `equipe` → PAS de dimension row-level native unique dans ERPNext v15
|
||||||
|
(ni « Team » standard). On refuse d'inventer un mécanisme
|
||||||
|
(contrainte #6) : ces rôles sont signalés « à confirmer VPS ».
|
||||||
|
|
||||||
|
Point CLÉ (anti-invention #6) : une `User Permission` Frappe est attachée à un
|
||||||
|
UTILISATEUR, pas à un Rôle. Ce worker ne connaît AUCUN utilisateur réel → il ne
|
||||||
|
peut produire qu'un TEMPLATE par rôle (champ `user` = sentinelle), que l'agent
|
||||||
|
ERPNext matérialisera par utilisateur assigné, côté VPS (jamais ici).
|
||||||
|
|
||||||
|
Références Frappe :
|
||||||
|
- DocType `User Permission` : restreint un utilisateur aux documents dont un
|
||||||
|
champ Link (`allow`) vaut `for_value`. `apply_to_all_doctypes=1` applique la
|
||||||
|
restriction à TOUS les DocTypes liés à `allow` (comportement natif v15).
|
||||||
|
- `Company` : chaque entité juridique OTO = une Company ERPNext (CLAUDE.md
|
||||||
|
§Entités). C'est le Link de restriction pour la portée `entite`.
|
||||||
|
|
||||||
|
Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Sentinelle du champ `user` d'un template : ce worker n'invente aucun
|
||||||
|
# utilisateur (anti-invention #6). L'agent ERPNext remplace par le vrai e-mail
|
||||||
|
# de chaque utilisateur assigné au rôle, côté VPS.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
USER_PLACEHOLDER = "__ASSIGN_PER_USER__"
|
||||||
|
|
||||||
|
# Le DocType Link cible de la restriction `entite` : la Company Frappe native.
|
||||||
|
ALLOW_COMPANY = "Company"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mécanismes d'enforcement row-level, un par valeur de scope_donnees.
|
||||||
|
# `mechanism` documente COMMENT la portée est appliquée côté Frappe (traçabilité
|
||||||
|
# pour l'agent ERPNext + le SPEC). Un seul émet un template `User Permission`.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
SCOPE_MECHANISM: dict[str, str] = {
|
||||||
|
"own": "docperm_if_owner", # posé par fixtures_gen (if_owner=1)
|
||||||
|
"entite": "user_permission_company",
|
||||||
|
"groupe": "none_consolidated", # vue consolidée → aucune restriction
|
||||||
|
"equipe": "vps_confirm_team", # pas de dimension native → à confirmer
|
||||||
|
}
|
||||||
|
|
||||||
|
# Le seul mécanisme qui produit un enregistrement `User Permission` template.
|
||||||
|
MECH_WITH_TEMPLATE = "user_permission_company"
|
||||||
|
|
||||||
|
# Entités (CLAUDE.md §Entités) qui NE sont PAS une Company restreignable :
|
||||||
|
# « Groupe » = périmètre consolidé transversal (jamais une seule Company).
|
||||||
|
NON_COMPANY_ENTITES: frozenset[str] = frozenset({"Groupe"})
|
||||||
|
|
||||||
|
|
||||||
|
def mechanism_for(scope: str) -> str:
|
||||||
|
"""Mécanisme Frappe natif d'enforcement pour un `scope_donnees` donné."""
|
||||||
|
if scope not in SCOPE_MECHANISM:
|
||||||
|
raise ValueError(
|
||||||
|
f"scope_donnees inconnu (hors contrat RBAC) : {scope!r}. "
|
||||||
|
f"Attendu ∈ {sorted(SCOPE_MECHANISM)}."
|
||||||
|
)
|
||||||
|
return SCOPE_MECHANISM[scope]
|
||||||
|
|
||||||
|
|
||||||
|
def user_permission_template(entite: str) -> dict[str, Any]:
|
||||||
|
"""Template `User Permission` (allow=Company) pour un rôle de portée `entite`.
|
||||||
|
|
||||||
|
`user` est une SENTINELLE : l'agent ERPNext clone ce template une fois par
|
||||||
|
utilisateur assigné au rôle et remplace `user` par son e-mail (côté VPS).
|
||||||
|
`for_value` = l'entité juridique du contrat (à confirmer comme Company VPS).
|
||||||
|
`name` est volontairement omis → Frappe l'auto-nomme à l'import.
|
||||||
|
"""
|
||||||
|
if entite in NON_COMPANY_ENTITES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Portée `entite` incohérente : {entite!r} est un périmètre consolidé "
|
||||||
|
f"(pas une Company). Une portée consolidée doit être `groupe`."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"doctype": "User Permission",
|
||||||
|
"user": USER_PLACEHOLDER,
|
||||||
|
"allow": ALLOW_COMPANY,
|
||||||
|
"for_value": entite,
|
||||||
|
"apply_to_all_doctypes": 1,
|
||||||
|
"is_default": 0,
|
||||||
|
"hide_descendants": 0,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user