Ajouter la gestion RBAC des utilisateurs et permissions
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
68a8ce2aad
commit
ab543cad3a
13 changed files with 314 additions and 7 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
66
app_new/core/models/rbac.py
Normal file
66
app_new/core/models/rbac.py
Normal 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")
|
||||
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 d’utilisateur 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
|
||||
|
|
|
|||
1
app_new/templates/admin/role_permissions.html
Normal file
1
app_new/templates/admin/role_permissions.html
Normal 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 %}
|
||||
1
app_new/templates/admin/roles.html
Normal file
1
app_new/templates/admin/roles.html
Normal 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 %}
|
||||
1
app_new/templates/admin/user_access.html
Normal file
1
app_new/templates/admin/user_access.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -49,4 +50,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
23
migrations/versions/9c17d8e9f0a1_ajouter_actions_rbac.py
Normal file
23
migrations/versions/9c17d8e9f0a1_ajouter_actions_rbac.py
Normal 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})
|
||||
|
||||
26
tests/integration/test_rbac.py
Normal file
26
tests/integration/test_rbac.py
Normal 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
|
||||
|
||||
Loading…
Reference in a new issue