RBAC — Contrôle d'accès par rôles et permissions¶
Vue d'ensemble¶
Le RBAC Forge est générique et sans ORM. Il fournit les briques nécessaires pour protéger les routes HTTP et afficher conditionnellement des éléments de template, sans imposer de modèle utilisateur ni de structure de base particulière.
| Brique | Fichier | Rôle |
|---|---|---|
| Modèles | core/security/rbac.py |
Role, Permission, normalisation, validation |
| Décorateur serveur | core/security/rbac.py |
@require_permission |
| Helper Jinja | core/security/rbac.py |
make_can / can(...) |
| Résolution | core/security/rbac.py |
get_request_permissions, has_permission |
| Tables SQL | mvc/models/sql/rbac.sql |
Schéma roles, permissions, role_permissions |
| CRUD déclaratif | forge_cli/entities/make_crud.py |
Injection de @require_permission à la génération |
Principe fondamental : Forge fournit le mécanisme d'autorisation. L'application fournit l'identité de l'utilisateur et la liste de ses permissions, après authentification.
RBAC et Auth/User¶
Le RBAC repond a la question : qu'a le droit de faire l'utilisateur ? La brique Auth/User repond a la question : qui est connecte ?
Auth/User fournit l'identite locale et RBAC fournit les roles et permissions.
La table optionnelle user_roles, ajoutee par AUTH-USER-RBAC-001, sert de pont
entre les deux mondes : elle associe un user_id a un role_id, sans stocker
de permissions dans users et sans deplacer la logique RBAC dans Auth/User.
AUTH-USER-RBAC-002 ajoute ensuite la resolution backend des permissions depuis l'utilisateur connecte, sans ajouter de logique Jinja ni d'interface admin.
Il existe donc deux modes de protection serveur, volontairement distincts :
| Mode | API | Source des permissions |
|---|---|---|
| RBAC historique | @require_permission(...) |
request.permissions ou session RBAC historique |
| Auth/User + RBAC | require_user_permission(...) |
session Auth/User puis user_roles -> roles -> permissions |
@require_permission(...) ne lit pas automatiquement user_roles.
require_user_permission(...) ne lit pas request.permissions ni
session["permissions"]. Cette separation permet aux applications existantes
de conserver leur RBAC historique tout en donnant un chemin clair aux projets
qui utilisent Auth/User.
Tables RBAC¶
Les trois tables sont déclarées dans mvc/models/sql/rbac.sql :
-- Rôles : admin, moderateur, lecteur…
CREATE TABLE IF NOT EXISTS roles (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Permissions atomiques : posts.edit, users.delete, dashboard.view…
CREATE TABLE IF NOT EXISTS permissions (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(150) NOT NULL UNIQUE,
label VARCHAR(255) NULL,
description TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Liaison rôle ↔ permission (clé primaire composite)
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_rp_role
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_rp_permission
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE,
INDEX idx_rp_permission (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Pour créer les tables dans votre base :
La table optionnelle user_roles est fournie par la brique Auth/User avancee
pour relier les utilisateurs locaux aux roles RBAC existants :
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
INDEX idx_user_roles_user_id (user_id),
INDEX idx_user_roles_role_id (role_id),
CONSTRAINT fk_user_roles_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role_id
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Cette table reste optionnelle. RBAC reste responsable des roles et permissions ; Auth/User ne devient pas un systeme d'autorisation.
Resolution Auth/User vers permissions¶
Le flux de lecture backend est maintenant explicite :
API cote Auth/User :
from core.auth import get_user_permissions, get_user_role_ids, user_has_permission
role_ids = get_user_role_ids(user_id)
permissions = get_user_permissions(user_id)
ok = user_has_permission(user_id, "articles.edit")
Les permissions restent des permissions RBAC. Auth/User ne les definit pas et
ne les stocke pas dans users.
Modèles Python¶
from core.security.rbac import Role, Permission
# Créer depuis une ligne SQL
role = Role.from_row({"id": 1, "name": "Administrateur", "slug": "admin"})
perm = Permission.from_row({"id": 1, "code": "posts.edit", "label": "Modifier articles"})
# Sérialiser
role.to_dict() # {"id": 1, "name": "Administrateur", "slug": "admin", "description": None}
perm.to_dict() # {"id": 1, "code": "posts.edit", "label": "Modifier articles", "description": None}
Normalisation¶
from core.security.rbac import normalize_role_slug, normalize_permission_code
normalize_role_slug("Super Admin") # → "super-admin"
normalize_role_slug("GESTIONNAIRE") # → "gestionnaire"
normalize_permission_code("Posts.Edit") # → "posts.edit"
normalize_permission_code("posts edit") # → "posts.edit"
Validation¶
from core.security.rbac import validate_role, validate_permission, RbacValidationError
validate_role("Admin", "admin") # OK
validate_role("", "admin") # → RbacValidationError
validate_role("Admin", "super admin") # → RbacValidationError (espace dans slug)
validate_permission("posts.edit") # OK
validate_permission("") # → RbacValidationError
validate_permission("postsedit") # → RbacValidationError (pas de point)
Résolution des permissions¶
from core.security.rbac import get_request_permissions, has_permission
perms = get_request_permissions(request) # → set[str]
ok = has_permission(request, "posts.edit") # → bool
Ordre de résolution :
request.permissions— injection directe (pratique pour les tests)session["utilisateur"]["permissions"]— depuis la session authentifiée- Ensemble vide si aucune source disponible
Ces deux sources sont contrôlées côté serveur. Forge ne lit jamais les permissions depuis les paramètres GET, le corps POST, les headers HTTP ni les cookies bruts. Voir Chaîne de confiance.
@require_permission — décorateur serveur¶
Usage¶
from core.security.rbac import require_permission
from core.security.decorators import require_auth
class PostController:
@staticmethod
@require_auth
@require_permission("posts.edit")
def edit(request):
...
@staticmethod
@require_auth
@require_permission("posts.delete")
def delete(request, post_id):
...
Comportement¶
- Valide le code à la décoration —
require_permission("postsedit")lèveRbacValidationErrorimmédiatement, sans attendre une requête. - Normalise le code (
"Posts.Edit"→"posts.edit") avant la vérification. - Retourne 403 si la permission est absente ; laisse passer si elle est présente.
- Préserve la signature via
functools.wraps.
Ajouter les permissions à la session¶
utilisateur = {
"UtilisateurId": row["id"],
"Login": row["login"],
"roles": ["admin"],
"permissions": ["posts.edit", "posts.delete", "users.view"],
}
nouveau_id = authentifier_session(session_id, utilisateur)
La clé "permissions" est lue par get_request_permissions depuis la session.
Injection dans les tests¶
def test_edit_requiert_permission():
req = FakeRequest("POST", "/posts/1/edit")
req.permissions = ["posts.edit"]
response = PostController.edit(req)
assert response.status == 200
def test_edit_refuse_sans_permission():
req = FakeRequest("POST", "/posts/1/edit")
response = PostController.edit(req)
assert response.status == 403
Protection serveur avec Auth/User¶
Forge fournit aussi une strategie serveur explicite pour les projets qui
utilisent Auth/User et la table optionnelle user_roles :
from core.auth import require_user_permission
@require_user_permission("posts.edit")
def edit(request, post_id):
...
require_user_permission(...) lit l'utilisateur connecte avec
get_authenticated_user_id(request), puis verifie ses permissions effectives
via le flux :
Comportement :
401 Unauthorizedsi aucun utilisateur Auth/User n'est connecte ;403 Forbiddensi l'utilisateur est connecte mais ne possede pas la permission ;- passage au controleur si
user_has_permission(user_id, permission)retourneTrue.
Le RBAC historique reste disponible. @require_permission(...) n'est pas
supprime et continue de lire uniquement les permissions deja presentes dans
request.permissions ou dans la session RBAC historique. make_can(request)
conserve aussi son comportement existant pour ce mode.
Attribution CLI des roles utilisateur¶
La CLI Auth/User peut manipuler la table optionnelle user_roles :
forge auth:user:role:add --email user@example.com --role admin
forge auth:user:role:remove --email user@example.com --role admin
forge auth:user:roles --email user@example.com
Ces commandes ne creent ni role, ni permission, ni utilisateur. Elles associent
ou dissocient seulement un utilisateur local existant et un role RBAC existant.
La definition des roles et permissions reste dans les tables RBAC historiques
roles, permissions et role_permissions.
can(...) — helper d'affichage Jinja¶
Usage dans les templates¶
{% if can("admin.users.manage") %}
<a href="/admin/users">Utilisateurs</a>
{% endif %}
{% if can("posts.edit") %}
<a href="/posts/{{ post.id }}/edit">Modifier</a>
{% endif %}
can(...) est injecté automatiquement dans les templates rendus via
BaseController.render(..., request=request). Si une session Auth/User contient
un utilisateur local et que le contexte Auth/User est disponible, le helper peut
passer par la resolution backend :
Sans session Auth/User, Forge preserve le comportement historique base sur les permissions deja presentes dans la requete ou la session.
can(...) reste un helper d'affichage. Il peut masquer un bouton ou un lien,
mais ne protege jamais une route a lui seul.
make_can — injection manuelle¶
from core.security.rbac import make_can
from tests.fake_request import FakeRequest
def test_menu_admin_visible():
req = FakeRequest()
req.permissions = ["admin.users.manage"]
ctx = {"can": make_can(req)}
html = renderer.render("layouts/nav.html", ctx)
assert "Utilisateurs" in html
Comportement¶
- Retourne
Truesi la permission est présente,Falsesinon. - Normalise automatiquement le code :
can("POSTS.EDIT")équivaut àcan("posts.edit"). - Retourne
Falsesi aucune permission n'est disponible. - Retourne
Falsesi aucun utilisateur n'est connecte dans le mode Auth/User. - Retourne
Falsesi les tables optionnellesuser_rolesou RBAC sont absentes. - Ne lève jamais d'exception visible dans le template.
- Ne cree aucune permission et ne modifie aucune table.
Différence entre @require_permission et can¶
@require_permission |
can(...) |
|
|---|---|---|
| Où | Décorateur Python côté serveur | Template Jinja2 côté HTML |
| Rôle | Protège une action HTTP | Affiche ou masque un élément |
| Retour | 403 si permission absente | True / False |
| Obligatoire pour la sécurité | Oui | Non |
Avertissement — Masquer un bouton dans le HTML n'est pas une sécurité suffisante. La route appelée doit aussi être protégée côté serveur avec
@require_permission(...)ourequire_user_permission(...). Un utilisateur peut appeler la route directement sans passer par le bouton.
rbac.permissions dans make:crud¶
Déclarer les permissions dans l'entité JSON¶
{
"entity": "Contact",
"table": "contacts",
"fields": ["..."],
"rbac": {
"permissions": {
"index": "contacts.view",
"show": "contacts.view",
"create": "contacts.create",
"store": "contacts.create",
"edit": "contacts.edit",
"update": "contacts.edit",
"delete": "contacts.delete"
}
}
}
Le bloc rbac est optionnel. Sans lui, le CRUD généré est identique à avant.
Actions supportées¶
| Clé JSON | Méthode générée | Route |
|---|---|---|
index |
index |
GET /contacts |
show |
show |
GET /contacts/{id} |
create |
new |
GET /contacts/new |
store |
create |
POST /contacts |
edit |
edit |
GET /contacts/{id}/edit |
update |
update |
POST /contacts/{id} |
delete |
destroy |
POST /contacts/{id}/delete |
Seules les actions déclarées dans rbac.permissions reçoivent un décorateur.
Code généré¶
Pour une entité Contact avec les permissions ci-dessus, make:crud génère :
from core.security.rbac import require_permission
from core.mvc.controller import BaseController
...
class ContactController(BaseController):
@staticmethod
@require_permission("contacts.view")
def index(request):
...
@staticmethod
@require_permission("contacts.edit")
def edit(request):
...
@staticmethod
@require_permission("contacts.delete")
def destroy(request):
...
L'import from core.security.rbac import require_permission n'est ajouté que
si au moins une permission est déclarée.
Règles de validation¶
- Le code doit utiliser la notation pointée :
"contacts.view"✓,"contactsview"✗ - La valeur doit être une chaîne non vide
- Les actions inconnues (ex.
"publish") produisent une erreur lors demake:crud - Les codes sont normalisés automatiquement :
"Contacts.View"→"contacts.view"
Chaîne de confiance¶
Rôles respectifs¶
| Acteur | Responsabilité |
|---|---|
| Forge | Fournit le mécanisme d'autorisation (require_permission, has_permission, can) |
| L'application | Fournit l'identité utilisateur après authentification |
| L'application | Fournit la source fiable des permissions (depuis la base, l'annuaire, etc.) |
Sources de permissions acceptées¶
Forge lit les permissions depuis deux sources, dans cet ordre :
request.permissions— injection directe par l'application après authentificationsession["utilisateur"]["permissions"]— champ de la session serveur
Sources refusées¶
| Source client | Statut |
|---|---|
Paramètres GET (?permissions=...) |
Refusé — jamais lu |
| Corps POST / formulaire | Refusé — jamais lu |
Body JSON ({"permissions": [...]}) |
Refusé — jamais lu |
Headers HTTP (X-Permissions: ...) |
Refusé — jamais lu |
Cookies bruts (hors session_id) |
Refusé — jamais lu |
Injecter les permissions correctement¶
# ✅ Correct — injection serveur après résolution applicative
def before_action(request, user):
request.permissions = load_permissions_for_user(user.id)
# ✅ Correct — via la session lors de l'authentification
utilisateur = {
"UtilisateurId": user.id,
"Login": user.login,
"roles": ["editor"],
"permissions": ["posts.edit", "posts.view"],
}
nouveau_id = authentifier_session(session_id, utilisateur)
# ❌ Interdit — permissions lues depuis le client
request.permissions = request.params.get("permissions")
request.permissions = request.body.get("permissions")
request.permissions = request.headers.get("X-Permissions")
request.permissions = request.cookies.get("permissions")
Ce que Forge ne fait pas encore¶
- L'injection Jinja Auth/User existe pour l'affichage, mais elle ne protege pas les routes a elle seule.
user_rolesformalise le lien optionneluser ↔ rôle; la resolution backend des permissions passe ensuite par les tables RBAC.- Les routes manuelles (hors
make:crud) doivent être protégées manuellement avec@require_permission(...). - Pas d'administration des rôles et permissions via une interface Forge.
- Pas de hiérarchie de rôles automatique.
Exemple complet minimal¶
1. Entité JSON avec permissions¶
{
"format_version": 1,
"entity": "Article",
"table": "articles",
"fields": [
{"name": "id", "column": "Id", "python_type": "int", "sql_type": "INT",
"primary_key": true, "auto_increment": true, "nullable": false, "constraints": {}},
{"name": "titre", "column": "Titre", "python_type": "str", "sql_type": "VARCHAR(200)",
"primary_key": false, "auto_increment": false, "nullable": false, "constraints": {}}
],
"rbac": {
"permissions": {
"index": "articles.view",
"show": "articles.view",
"create": "articles.create",
"store": "articles.create",
"edit": "articles.edit",
"update": "articles.edit",
"delete": "articles.delete"
}
}
}
2. Générer le CRUD¶
3. Template avec can¶
4. Authentification avec permissions¶
utilisateur = {
"UtilisateurId": row["id"],
"Login": row["login"],
"roles": ["redacteur"],
"permissions": ["articles.view", "articles.edit"],
}
nouveau_id = authentifier_session(session_id, utilisateur)
5. Test¶
from tests.fake_request import FakeRequest
from mvc.controllers.article_controller import ArticleController
def test_edit_avec_permission():
req = FakeRequest()
req.permissions = ["articles.edit"]
req.route_params = {"id": "1"}
# La méthode edit n'est appelée que si la permission est présente
# (nécessite une BDD pour aller plus loin — ici on vérifie le gardien)
def test_edit_sans_permission():
req = FakeRequest()
response = ArticleController.edit(req)
assert response.status == 403
Erreurs fréquentes¶
| Symptôme | Cause probable | Solution |
|---|---|---|
RbacValidationError à l'import |
Code sans point ("postsedit") |
Utiliser la notation pointée : "posts.edit" |
RbacValidationError à l'import |
Code vide ("") |
Fournir un code non vide |
| 403 inattendu | request.permissions non injecté |
Injecter après authentification ou via la session |
can(...) toujours False |
Template rendu sans request=request |
Passer request=request à BaseController.render(...) |
can(...) toujours False |
Permissions absentes de la session historique | Ajouter la clé "permissions" lors de authentifier_session |
can(...) toujours False |
Utilisateur Auth/User sans rôle ou tables RBAC absentes | Initialiser les SQL optionnels et associer l'utilisateur via user_roles |
| Code normalisé dans le JSON | "Contacts.View" au lieu de "contacts.view" |
Aucun problème — la normalisation est automatique |
EntityDefinitionError à la génération |
Action inconnue dans rbac.permissions ("publish") |
Utiliser uniquement : index, show, create, store, edit, update, delete |
Limites restantes¶
- Jinja n'est pas une protection serveur —
can(...)masque ou affiche des elements d'interface, mais une route sensible doit toujours etre protegee cote backend avec@require_permission(...)ou une verification equivalente. - Pas de
deny by defaultautomatique — une route sans@require_permissionest accessible. La politique de refus par défaut dépend du groupe de routes (router.group(...)). - Pas d'ORM — les tables SQL sont lisibles et exécutables directement. Les
JOINuser ↔ role ↔ permissionrestent explicites dans le resolver Auth/User -> RBAC. - Deux strategies coexistent — le RBAC historique et Auth/User + RBAC sont separes. Aucun decorateur ne bascule implicitement vers l'autre mode.
- Pas de cache distribué — les permissions peuvent etre resolues depuis les tables optionnelles RBAC a chaque rendu concerne.
- Pas de hiérarchie de rôles — un rôle
adminn'hérite pas automatiquement des permissions d'un rôleediteur.