Ajouter la gestion RBAC des utilisateurs et permissions
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-21 15:22:47 +00:00
parent 68a8ce2aad
commit ab543cad3a
13 changed files with 314 additions and 7 deletions

View file

@ -77,6 +77,14 @@ STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"}
CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"}
PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"}
# Catalogue affiché dans la matrice RBAC. Les anciennes permissions
# ``*.manage`` restent compatibles et impliquent les actions ci-dessous.
GRANULAR_PERMISSION_CODES = {
f"{module}.{action}"
for module in ("dashboard", "patrimoine", "intervention", "planning", "stock", "contract", "prevention", "user", "system")
for action in ("view", "create", "edit", "delete", "validate", "reject", "export", "configure", "manage")
}
def canonical_role(role):
return ROLE_ALIASES.get((role or "").strip().lower(), "lecture")
@ -86,8 +94,38 @@ def has_permission(permission, user=None):
user = user or current_user
if not getattr(user, "is_authenticated", False):
return False
# Les rôles configurables sont prioritaires lorsqu'ils existent. Le
# fallback statique conserve la compatibilité avec les anciennes bases
# avant application de la migration RBAC.
try:
from .models.rbac import Role, RolePermission, Permission, UserPermission
from datetime import datetime, timezone
roles = [link.role for link in user.role_links if link.role and link.role.is_active]
legacy_role = Role.query.filter_by(slug=user.role, is_active=True).first()
if legacy_role and legacy_role not in roles:
roles.append(legacy_role)
if roles:
granted = {link.permission.code for role in roles for link in role.permission_links
if link.effect == "allow" and link.permission.is_active}
denied = set()
for override in user.permission_links:
if override.expires_at and override.expires_at < datetime.now(timezone.utc):
continue
(granted if override.effect == "allow" else denied).add(override.permission.code)
if permission in denied:
return False
if permission in granted or "*" in granted:
return True
module = permission.rsplit(".", 1)[0] if "." in permission else permission
return f"{module}.manage" in granted and f"{module}.manage" not in denied
except Exception:
# Une base non migrée ne doit pas rendre toutes les routes indisponibles.
pass
permissions = PERMISSIONS_BY_ROLE.get(canonical_role(user.role), set())
return "*" in permissions or permission in permissions
if "*" in permissions or permission in permissions:
return True
module = permission.rsplit(".", 1)[0] if "." in permission else permission
return f"{module}.manage" in permissions
def permission_required(permission):
@ -108,6 +146,16 @@ def required_permission(endpoint, method):
blueprint = endpoint.split(".", 1)[0]
mutating = method not in {"GET", "HEAD", "OPTIONS"}
if blueprint == "cleaning":
action = "view"
if mutating:
action = "create" if any(token in endpoint for token in ("new", "receive", "issue", "transfer", "transvasement", "inventory")) else "edit"
if "delete" in endpoint:
action = "delete"
if "validate" in endpoint:
action = "validate"
return f"stock.{action}"
if blueprint in ADMIN_BLUEPRINTS:
return "system.admin"
if blueprint == "auth":

View file

@ -26,6 +26,7 @@ from .cleaning import (
RequestProfile, RequestProfileItem, StockInventory, StockInventoryLine,
CleaningForecastConfig,
)
from .rbac import Role, Permission, UserRole, RolePermission, UserPermission
__all__ = [
'User', 'Staff',
@ -45,4 +46,5 @@ __all__ = [
'MaterialAssignment', 'ReusableContainer', 'ContainerFill', 'ProductDocument',
'RequestProfile', 'RequestProfileItem', 'StockInventory', 'StockInventoryLine',
'CleaningForecastConfig',
'Role', 'Permission', 'UserRole', 'RolePermission', 'UserPermission',
]

View file

@ -0,0 +1,66 @@
"""RBAC configurable : rôles, permissions et exceptions utilisateur."""
from datetime import datetime, timezone
from ...extensions import db
def utcnow():
return datetime.now(timezone.utc)
class Role(db.Model):
__tablename__ = "roles"
id = db.Column(db.Integer, primary_key=True)
slug = db.Column(db.String(80), nullable=False, unique=True)
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text)
is_system = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False, default=utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=utcnow, onupdate=utcnow)
user_links = db.relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
permission_links = db.relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
class Permission(db.Model):
__tablename__ = "permissions"
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(120), nullable=False, unique=True)
name = db.Column(db.String(160), nullable=False)
module = db.Column(db.String(80), nullable=False, index=True)
action = db.Column(db.String(40), nullable=False)
description = db.Column(db.Text)
is_active = db.Column(db.Boolean, nullable=False, default=True)
role_links = db.relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
user_links = db.relationship("UserPermission", back_populates="permission", cascade="all, delete-orphan")
class UserRole(db.Model):
__tablename__ = "user_roles"
__table_args__ = (db.UniqueConstraint("user_id", "role_id", name="uq_user_role"),)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id = db.Column(db.Integer, db.ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_at = db.Column(db.DateTime, nullable=False, default=utcnow)
assigned_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"))
user = db.relationship("User", foreign_keys=[user_id], back_populates="role_links")
role = db.relationship("Role", back_populates="user_links")
assigned_by = db.relationship("User", foreign_keys=[assigned_by_id])
class RolePermission(db.Model):
__tablename__ = "role_permissions"
__table_args__ = (db.UniqueConstraint("role_id", "permission_id", name="uq_role_permission"),)
id = db.Column(db.Integer, primary_key=True)
role_id = db.Column(db.Integer, db.ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
permission_id = db.Column(db.Integer, db.ForeignKey("permissions.id", ondelete="CASCADE"), nullable=False, index=True)
effect = db.Column(db.String(10), nullable=False, default="allow")
role = db.relationship("Role", back_populates="permission_links")
permission = db.relationship("Permission", back_populates="role_links")
class UserPermission(db.Model):
__tablename__ = "user_permissions"
__table_args__ = (db.UniqueConstraint("user_id", "permission_id", name="uq_user_permission"),)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
permission_id = db.Column(db.Integer, db.ForeignKey("permissions.id", ondelete="CASCADE"), nullable=False, index=True)
effect = db.Column(db.String(10), nullable=False, default="allow")
expires_at = db.Column(db.DateTime)
note = db.Column(db.String(255))
user = db.relationship("User", back_populates="permission_links")
permission = db.relationship("Permission", back_populates="user_links")

View file

@ -22,6 +22,8 @@ class User(UserMixin, db.Model):
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
last_login = db.Column(db.DateTime, nullable=True)
role_links = db.relationship("UserRole", foreign_keys="UserRole.user_id", back_populates="user", cascade="all, delete-orphan")
permission_links = db.relationship("UserPermission", foreign_keys="UserPermission.user_id", back_populates="user", cascade="all, delete-orphan")
def set_password(self, password):
"""Hash et stocke le mot de passe."""

View file

@ -12,6 +12,7 @@ from ...extensions import db
from ..models.user import User, Staff
from ..models.college import College, Building, Room, RoomType
from ..models.company import Company, Service
from ..models.rbac import Role, Permission, UserRole, RolePermission, UserPermission
from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES
from ..authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS, canonical_role
@ -54,7 +55,7 @@ def users():
"""Liste des utilisateurs."""
page = request.args.get('page', 1, type=int)
users = User.query.order_by(User.created_at.desc()).paginate(page=page, per_page=20)
return render_template('admin/users.html', users=users)
return render_template('admin/users.html', users=users, rbac_roles=Role.query.filter_by(is_active=True).order_by(Role.name).all())
@admin_bp.route('/users/new', methods=['GET', 'POST'])
@ -68,6 +69,7 @@ def user_new():
password = request.form.get('password') or ''
password_confirm = request.form.get('password_confirm') or ''
role = request.form.get('role', 'demandeur')
valid_roles = set(ROLES) | {r.slug for r in Role.query.filter_by(is_active=True).all()}
if len(username) < 3:
flash('Le nom dutilisateur doit contenir au moins 3 caractères.', 'danger')
@ -77,7 +79,7 @@ def user_new():
flash('Le mot de passe doit contenir au moins 12 caractères.', 'danger')
elif password != password_confirm:
flash('Les mots de passe ne correspondent pas.', 'danger')
elif role not in ROLES:
elif role not in valid_roles:
flash('Rôle utilisateur invalide.', 'danger')
elif User.query.filter_by(username=username).first():
flash('Ce nom d\'utilisateur existe déjà.', 'danger')
@ -87,11 +89,16 @@ def user_new():
user = User(username=username, email=email, role=role)
user.set_password(password)
db.session.add(user)
db.session.flush()
selected_role = Role.query.filter_by(slug=role, is_active=True).first()
if selected_role:
db.session.add(UserRole(user_id=user.id, role_id=selected_role.id, assigned_by_id=current_user.id))
db.session.commit()
flash('Utilisateur créé avec succès.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', roles=ROLES)
roles = {**ROLES, **{r.slug: r.name for r in Role.query.filter_by(is_active=True).all()}}
return render_template('admin/user_form.html', roles=roles)
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@ -103,7 +110,8 @@ def user_edit(user_id):
if request.method == 'POST':
role = request.form.get('role')
if role not in ROLES:
valid_roles = set(ROLES) | {r.slug for r in Role.query.filter_by(is_active=True).all()}
if role not in valid_roles:
flash('Rôle utilisateur invalide.', 'danger')
return render_template('admin/user_form.html', user=user, roles=ROLES), 400
user.email = request.form.get('email')
@ -117,11 +125,15 @@ def user_edit(user_id):
return render_template('admin/user_form.html', user=user), 400
user.set_password(new_password)
selected_role = Role.query.filter_by(slug=role, is_active=True).first()
if selected_role and not UserRole.query.filter_by(user_id=user.id, role_id=selected_role.id).first():
db.session.add(UserRole(user_id=user.id, role_id=selected_role.id, assigned_by_id=current_user.id))
db.session.commit()
flash('Utilisateur modifié avec succès.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', user=user, roles=ROLES)
roles = {**ROLES, **{r.slug: r.name for r in Role.query.filter_by(is_active=True).all()}}
return render_template('admin/user_form.html', user=user, roles=roles)
@admin_bp.route('/permissions')
@ -139,6 +151,89 @@ def permissions():
)
@admin_bp.route('/roles', methods=['GET', 'POST'])
@login_required
@admin_required
def roles():
"""Gérer les rôles configurables sans supprimer les rôles historiques."""
if request.method == 'POST':
name = (request.form.get('name') or '').strip()
slug = (request.form.get('slug') or '').strip().lower().replace(' ', '_')
if not name or len(slug) < 2:
flash('Le nom et le code du rôle sont obligatoires.', 'danger')
elif Role.query.filter_by(slug=slug).first():
flash('Ce code de rôle existe déjà.', 'danger')
else:
db.session.add(Role(slug=slug, name=name, description=request.form.get('description'), is_system=False))
db.session.commit()
flash('Rôle créé. Configurez maintenant ses permissions.', 'success')
return redirect(url_for('admin.role_permissions', role_id=Role.query.filter_by(slug=slug).first().id))
return render_template('admin/roles.html', roles_list=Role.query.order_by(Role.is_system.desc(), Role.name).all())
@admin_bp.route('/roles/<int:role_id>/permissions', methods=['GET', 'POST'])
@login_required
@admin_required
def role_permissions(role_id):
role = Role.query.get_or_404(role_id)
permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all()
if request.method == 'POST':
selected = {int(value) for value in request.form.getlist('permission_ids')}
RolePermission.query.filter_by(role_id=role.id).delete(synchronize_session=False)
for permission in permissions_list:
if permission.id in selected:
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect='allow'))
db.session.commit()
flash(f'Permissions du rôle « {role.name} » enregistrées.', 'success')
return redirect(url_for('admin.roles'))
selected = {link.permission_id for link in role.permission_links if link.effect == 'allow'}
return render_template('admin/role_permissions.html', role=role, permissions_list=permissions_list, selected=selected)
@admin_bp.route('/roles/<int:role_id>/archive', methods=['POST'])
@login_required
@admin_required
def role_archive(role_id):
role = Role.query.get_or_404(role_id)
if role.is_system:
flash('Les rôles système ne peuvent pas être supprimés.', 'danger')
else:
role.is_active = False
db.session.commit()
flash(f'Rôle « {role.name} » archivé. Son historique est conservé.', 'success')
return redirect(url_for('admin.roles'))
@admin_bp.route('/users/<int:user_id>/access', methods=['GET', 'POST'])
@login_required
@admin_required
def user_access(user_id):
user = User.query.get_or_404(user_id)
roles_list = Role.query.filter_by(is_active=True).order_by(Role.name).all()
permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all()
if request.method == 'POST':
selected_roles = {int(value) for value in request.form.getlist('role_ids')}
UserRole.query.filter_by(user_id=user.id).delete(synchronize_session=False)
for role in roles_list:
if role.id in selected_roles:
db.session.add(UserRole(user_id=user.id, role_id=role.id, assigned_by_id=current_user.id))
# Les exceptions explicites sont optionnelles et tracées ; une case
# « refuser » est prioritaire sur les rôles.
UserPermission.query.filter_by(user_id=user.id).delete(synchronize_session=False)
for permission in permissions_list:
effect = request.form.get(f'permission_{permission.id}')
if effect in ('allow', 'deny'):
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect=effect, note='Exception administrateur'))
if selected_roles:
user.role = next((r.slug for r in roles_list if r.id in selected_roles), user.role)
db.session.commit()
flash(f'Accès de {user.username} enregistrés.', 'success')
return redirect(url_for('admin.users'))
selected_roles = {link.role_id for link in user.role_links}
overrides = {link.permission_id: link.effect for link in user.permission_links}
return render_template('admin/user_access.html', user=user, roles_list=roles_list, permissions_list=permissions_list, selected_roles=selected_roles, overrides=overrides)
@admin_bp.route('/audit')
@login_required
@admin_required

View file

@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}Permissions — {{ role.name }}{% endblock %}{% block content %}<div class="container-fluid"><h1>Permissions du rôle « {{ role.name }} »</h1><p class="text-muted">Les cases cochées autorisent l'action. Les permissions sont regroupées par module.</p><form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% set ns = namespace(module='') %}{% for permission in permissions_list %}{% if permission.module != ns.module %}{% if not loop.first %}</div></div>{% endif %}{% set ns.module = permission.module %}<div class="card mb-3"><div class="card-header"><strong>{{ permission.module|title }}</strong></div><div class="card-body row g-2">{% endif %}<div class="col-xl-3 col-md-4"><label class="border rounded p-2 d-block"><input type="checkbox" name="permission_ids" value="{{ permission.id }}" {% if permission.id in selected %}checked{% endif %}> <strong>{{ permission.action }}</strong><br><small class="text-muted">{{ permission.name }}</small></label></div>{% if loop.last %}</div></div>{% endif %}{% else %}<p class="text-muted">Aucune permission. Exécutez la migration RBAC.</p>{% endfor %}<button class="btn btn-primary">Enregistrer les permissions</button><a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.roles') }}">Annuler</a></form></div>{% endblock %}

View file

@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}Rôles configurables{% endblock %}{% block content %}<div class="container-fluid"><div class="d-flex justify-content-between align-items-center"><h1>Rôles et permissions</h1><a class="btn btn-outline-primary" href="{{ url_for('admin.permissions') }}">Vue historique</a></div><div class="card my-3"><div class="card-body"><h5>Créer un rôle</h5><form method="post" class="row g-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-md-3"><input class="form-control" name="name" placeholder="Gestionnaire stock" required></div><div class="col-md-3"><input class="form-control" name="slug" placeholder="gestionnaire_stock" required></div><div class="col-md-4"><input class="form-control" name="description" placeholder="Description"></div><div class="col-md-2"><button class="btn btn-primary w-100">Créer</button></div></form></div></div><table class="table table-hover"><tr><th>Rôle</th><th>Code</th><th>Type</th><th>Utilisateurs</th><th></th></tr>{% for role in roles_list %}<tr><td><strong>{{ role.name }}</strong><br><small>{{ role.description or '' }}</small></td><td><code>{{ role.slug }}</code></td><td>{{ 'Système' if role.is_system else 'Personnalisé' }}</td><td>{{ role.user_links|length }}</td><td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('admin.role_permissions', role_id=role.id) }}">Configurer les droits</a>{% if not role.is_system and role.is_active %}<form class="d-inline" method="post" action="{{ url_for('admin.role_archive', role_id=role.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger" onclick="return confirm('Archiver ce rôle ?')">Archiver</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="5">Aucun rôle RBAC migré.</td></tr>{% endfor %}</table></div>{% endblock %}

View file

@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}Accès — {{ user.username }}{% endblock %}{% block content %}<div class="container-fluid"><h1>Accès de {{ user.full_name or user.username }}</h1><form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card mb-3"><div class="card-header"><strong>Rôles</strong></div><div class="card-body row g-2">{% for role in roles_list %}<div class="col-md-3"><label class="border rounded p-2 d-block"><input type="checkbox" name="role_ids" value="{{ role.id }}" {% if role.id in selected_roles %}checked{% endif %}> {{ role.name }}<br><small><code>{{ role.slug }}</code></small></label></div>{% else %}<p>Aucun rôle RBAC disponible.</p>{% endfor %}</div></div><div class="card mb-3"><div class="card-header"><strong>Exceptions personnelles</strong><small class="d-block text-muted">Accorder ou refuser ici un droit en dehors des rôles. Un refus est prioritaire.</small></div><div class="card-body row g-2">{% for permission in permissions_list %}<div class="col-xl-3 col-md-4"><label class="small">{{ permission.code }}</label><select class="form-select form-select-sm" name="permission_{{ permission.id }}"><option value="">Hérité du rôle</option><option value="allow" {% if overrides.get(permission.id)=='allow' %}selected{% endif %}>Autoriser</option><option value="deny" {% if overrides.get(permission.id)=='deny' %}selected{% endif %}>Refuser</option></select></div>{% else %}<p>Aucune permission RBAC disponible.</p>{% endfor %}</div></div><button class="btn btn-primary">Enregistrer les accès</button><a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.users') }}">Annuler</a></form></div>{% endblock %}

View file

@ -37,6 +37,7 @@
<a href="{{ url_for('admin.user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{{ url_for('admin.user_access', user_id=user.id) }}" class="btn btn-sm btn-outline-secondary">Accès</a>
</td>
</tr>
{% endfor %}

View file

@ -276,6 +276,7 @@
<li><a class="dropdown-item" href="{{ url_for('chatbot.chatbot_interface') }}"><i class="bi bi-robot"></i> Chatbot</a></li>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.list_users') }}"><i class="bi bi-people"></i> Utilisateurs</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles') }}"><i class="bi bi-shield-lock"></i> Rôles et permissions</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.data_management') }}"><i class="bi bi-database-exclamation text-danger"></i> Gestion des données</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.template_debug') }}"><i class="bi bi-files"></i> Audit des templates</a></li>
<li><a class="dropdown-item" href="{{ url_for('setup_wizard.admin_panel') }}"><i class="bi bi-database"></i> Gestion bases</a></li>

View file

@ -0,0 +1,40 @@
"""Ajouter le RBAC configurable (rôles, permissions et exceptions).
Revision ID: 9b06c7d8e9f0
Revises: 8a95b1c2d3e4
"""
from alembic import op
import sqlalchemy as sa
from app_new.core.models.rbac import Role, Permission, UserRole, RolePermission, UserPermission
from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS, GRANULAR_PERMISSION_CODES
revision = "9b06c7d8e9f0"
down_revision = "8a95b1c2d3e4"
branch_labels = None
depends_on = None
_TABLES = [Role.__table__, Permission.__table__, UserRole.__table__, RolePermission.__table__, UserPermission.__table__]
def upgrade():
bind = op.get_bind()
for table in _TABLES:
table.create(bind=bind, checkfirst=True)
roles = {slug: ROLE_LABELS.get(slug, slug) for slug in PERMISSIONS_BY_ROLE}
for slug, name in roles.items():
bind.execute(sa.text("INSERT INTO roles (slug,name,description,is_system,is_active,created_at,updated_at) SELECT :slug,:name,:description,1,1,NOW(),NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug=:slug)"), {"slug": slug, "name": name, "description": "Rôle système historique"})
permission_codes = sorted({code for values in PERMISSIONS_BY_ROLE.values() for code in values} | GRANULAR_PERMISSION_CODES)
for code in permission_codes:
if code == "*":
module, action, name = "system", "all", "Accès complet"
else:
bits = code.split(".", 1); module = bits[0]; action = bits[1] if len(bits) > 1 else "manage"; name = f"{module.title()}{action}"
bind.execute(sa.text("INSERT INTO permissions (code,name,module,action,description,is_active) SELECT :code,:name,:module,:action,:description,1 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code=:code)"), {"code": code, "name": name, "module": module, "action": action, "description": "Permission initiale compatible avec l'ancienne matrice"})
for slug, codes in PERMISSIONS_BY_ROLE.items():
role_id = bind.execute(sa.text("SELECT id FROM roles WHERE slug=:slug"), {"slug": slug}).scalar()
for code in codes:
permission_id = bind.execute(sa.text("SELECT id FROM permissions WHERE code=:code"), {"code": code}).scalar()
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id,effect) SELECT :role_id,:permission_id,'allow' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM role_permissions WHERE role_id=:role_id AND permission_id=:permission_id)"), {"role_id": role_id, "permission_id": permission_id})
# Rattacher les comptes existants à leur rôle historique sans modifier leur rôle.
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id,assigned_at) SELECT u.id,r.id,NOW() FROM users u JOIN roles r ON r.slug=u.role WHERE NOT EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id=u.id AND ur.role_id=r.id)"))
def downgrade():
bind = op.get_bind()
for table in reversed(_TABLES):
table.drop(bind=bind, checkfirst=True)

View file

@ -0,0 +1,23 @@
"""Ajouter les actions granulaires au catalogue RBAC.
Revision ID: 9c17d8e9f0a1
Revises: 9b06c7d8e9f0
"""
from alembic import op
import sqlalchemy as sa
from app_new.core.authorization import GRANULAR_PERMISSION_CODES
revision = "9c17d8e9f0a1"
down_revision = "9b06c7d8e9f0"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
for code in sorted(GRANULAR_PERMISSION_CODES):
bits = code.split(".", 1)
module = bits[0]
action = bits[1] if len(bits) > 1 else "manage"
bind.execute(sa.text("INSERT INTO permissions (code,name,module,action,description,is_active) SELECT :code,:name,:module,:action,:description,1 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code=:code)"), {"code": code, "name": f"{module.title()}{action}", "module": module, "action": action, "description": "Action granulaire configurable"})
def downgrade():
bind = op.get_bind()
for code in sorted(GRANULAR_PERMISSION_CODES):
bind.execute(sa.text("DELETE FROM permissions WHERE code=:code"), {"code": code})

View file

@ -0,0 +1,26 @@
from uuid import uuid4
from app_new.extensions import db
from app_new.core.models import User, Role, Permission, RolePermission, UserPermission
from app_new.core.authorization import has_permission
def test_custom_role_permission_and_override(app):
with app.app_context():
slug = "custom_" + uuid4().hex[:8]
role = Role(slug=slug, name="Gestionnaire test", is_system=False)
permission = Permission(code="stock.test.view", name="Stock test", module="stock", action="view")
db.session.add_all([role, permission]); db.session.flush()
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect="allow"))
user = User(username="rbac_" + uuid4().hex[:8], email=uuid4().hex + "@test.local", full_name="RBAC", role=slug)
user.set_password("test-password-strong")
db.session.add(user); db.session.commit()
assert has_permission("stock.test.view", user)
assert not has_permission("stock.test.edit", user)
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect="deny"))
db.session.commit()
assert not has_permission("stock.test.view", user)
def test_admin_rbac_page_requires_authentication(client):
response = client.get("/admin/roles")
assert response.status_code == 302
assert "/auth/login" in response.location