Refondre le RBAC multi-roles et securiser les acces
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-21 16:46:19 +00:00
parent fec702ce38
commit 785c8aabcb
38 changed files with 506 additions and 633 deletions

View file

@ -11,27 +11,33 @@ from app_new.extensions import db
api_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
def api_key_required(f):
def api_key_required(permission=None):
"""Authentification par clé API (header X-API-Key) ou session login."""
@wraps(f)
def decorated(*args, **kwargs):
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
# Accepter soit un header X-API-Key, soit une session Flask-Login
api_key = request.headers.get('X-API-Key')
if api_key:
from app_new.core.models.settings import AppSettings
api_key_hash = AppSettings.get('api_key_hash')
if api_key_hash and check_password_hash(api_key_hash, api_key):
# La clé historique reste une clé de service à accès complet.
return f(*args, **kwargs)
return jsonify({'error': 'Clé API invalide'}), 401
# Sinon, fallback sur session login
if current_user.is_authenticated:
return f(*args, **kwargs)
return jsonify({'error': 'Authentification requise (X-API-Key ou login)'}), 401
return decorated
if not current_user.is_authenticated:
return jsonify({'error': 'Authentification requise (X-API-Key ou login)'}), 401
from app_new.core.authorization import has_permission
if permission and not has_permission(permission, current_user):
return jsonify({'error': 'Permission insuffisante', 'permission': permission}), 403
return f(*args, **kwargs)
return decorated
return decorator
@api_bp.route('/status')
@api_key_required
@api_key_required('dashboard.view')
def status():
"""État global du système."""
from app_new.core.models.maintenance import Intervention
@ -50,7 +56,7 @@ def status():
@api_bp.route('/interventions')
@api_key_required
@api_key_required('intervention.view')
def list_interventions():
"""Liste des interventions avec pagination."""
from app_new.core.models.maintenance import Intervention
@ -87,7 +93,7 @@ def list_interventions():
@api_bp.route('/interventions/<int:intervention_id>')
@api_key_required
@api_key_required('intervention.view')
def get_intervention(intervention_id):
"""Détail d'une intervention."""
from app_new.core.models.maintenance import Intervention
@ -111,7 +117,7 @@ def get_intervention(intervention_id):
@api_bp.route('/equipments')
@api_key_required
@api_key_required('patrimoine.view')
def list_equipments():
"""Liste des équipements avec pagination."""
from app_new.core.models.equipment import Equipment
@ -144,7 +150,7 @@ def list_equipments():
@api_bp.route('/equipments/<int:equipment_id>')
@api_key_required
@api_key_required('patrimoine.view')
def get_equipment(equipment_id):
"""Détail d'un équipement."""
from app_new.core.models.equipment import Equipment

View file

@ -6,7 +6,7 @@ retombe plus sur le simple fait d'être connecté.
"""
from functools import wraps
from flask import abort, request
from flask import abort, request, redirect, url_for, jsonify, current_app
from flask_login import current_user
@ -36,6 +36,8 @@ ROLE_LABELS = {
"lecture": "Lecture seule",
}
# LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le
# bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation.
PERMISSIONS_BY_ROLE = {
"admin": {"*"},
"responsable_gmao": {
@ -115,45 +117,55 @@ def canonical_role(role):
def has_permission(permission, user=None):
"""Retourne le droit effectif, en mode fail-closed.
La matrice SQL est la seule source de vérité à l'exécution : tous les
rôles actifs sont réunis, puis les exceptions individuelles sont appliquées
avec priorité aux refus. Aucune valeur de ``User.role`` n'est consultée.
"""
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 .models.rbac import RolePermission
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:
now = datetime.now(timezone.utc)
expiry = override.expires_at
if expiry and expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
if expiry and expiry < now:
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
granted = {
link.permission.code
for role in roles
for link in role.permission_links
if link.effect == "allow" and link.permission and link.permission.is_active
}
else:
granted = set()
now = datetime.now(timezone.utc)
allowed_overrides = set()
denied = set()
for override in user.permission_links:
expiry = override.expires_at
if expiry and expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
if expiry and expiry < now:
continue
if not override.permission or not override.permission.is_active:
continue
if override.effect == "deny":
denied.add(override.permission.code)
elif override.effect == "allow":
allowed_overrides.add(override.permission.code)
granted.update(allowed_overrides)
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())
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
current_app.logger.exception("Erreur RBAC lors du calcul de %s pour l'utilisateur %s", permission, getattr(user, "id", None))
return False
def permission_required(permission):
@ -246,7 +258,9 @@ def enforce_route_permission():
if endpoint and endpoint.startswith("api_v1."):
return None
if not current_user.is_authenticated:
return None # Flask-Login conserve la redirection habituelle des vues protégées.
if request.path.startswith('/api/') or request.is_json or request.accept_mimetypes.best == 'application/json':
return jsonify({'error': 'Authentification requise'}), 401
return redirect(url_for('auth.login', next=request.full_path.rstrip('?')))
permission = required_permission(endpoint, request.method)
if permission and not has_permission(permission):
abort(403)

View file

@ -34,9 +34,24 @@ class User(UserMixin, db.Model):
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Vérifie si l'utilisateur est admin."""
from ..authorization import canonical_role
return canonical_role(self.role) == "admin"
"""Vérifie l'administration via le RBAC réel, jamais ``User.role``."""
try:
for link in self.role_links:
if link.role and link.role.is_active and link.role.slug == "admin":
return True
if link.role and link.role.is_active:
if any(
permission.effect == "allow"
and permission.permission
and permission.permission.is_active
and permission.permission.code == "*"
for permission in link.role.permission_links
):
return True
except Exception:
from flask import current_app
current_app.logger.exception("Erreur RBAC lors de la vérification admin")
return False
@property
def canonical_role(self):
@ -46,7 +61,10 @@ class User(UserMixin, db.Model):
@property
def role_label(self):
from ..authorization import ROLE_LABELS
return ROLE_LABELS.get(self.canonical_role, self.role or "")
roles = [link.role for link in self.role_links if link.role and link.role.is_active]
if roles:
return ", ".join(role.name for role in sorted(roles, key=lambda item: item.name.lower()))
return "Aucun rôle RBAC"
def has_permission(self, permission):
from ..authorization import has_permission

View file

@ -14,7 +14,8 @@ 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, has_permission, required_permission, PERMISSION_LABELS
from ..authorization import ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS
from ..services.rbac import replace_user_roles
admin_bp = Blueprint('admin', __name__)
@ -25,7 +26,7 @@ def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
required = required_permission(request.endpoint, request.method)
if not current_user.is_authenticated or (not current_user.is_admin() and not has_permission(required)):
if not current_user.is_authenticated or not has_permission(required):
flash('Accès réservé aux administrateurs.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
@ -69,8 +70,11 @@ def user_new():
email = (request.form.get('email') or '').strip()
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()}
selected_role_ids = request.form.getlist('role_ids')
selected_role = request.form.get('role')
if not selected_role_ids and selected_role:
legacy_role = Role.query.filter_by(slug=selected_role, is_active=True).first()
selected_role_ids = [legacy_role.id] if legacy_role else []
if len(username) < 3:
flash('Le nom dutilisateur doit contenir au moins 3 caractères.', 'danger')
@ -80,26 +84,22 @@ 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 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')
elif User.query.filter_by(email=email).first():
flash('Cet email existe déjà.', 'danger')
else:
user = User(username=username, email=email, role=role)
user = User(username=username, email=email, role='demandeur')
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))
replace_user_roles(user, selected_role_ids, assigned_by=current_user)
db.session.commit()
flash('Utilisateur créé avec succès.', 'success')
return redirect(url_for('admin.users'))
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)
roles_list = Role.query.filter_by(is_active=True).order_by(Role.name).all()
return render_template('admin/user_form.html', roles_list=roles_list, selected_roles=set())
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@ -110,13 +110,12 @@ def user_edit(user_id):
user = User.query.get_or_404(user_id)
if request.method == 'POST':
role = request.form.get('role')
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
selected_role_ids = request.form.getlist('role_ids')
selected_role = request.form.get('role')
if not selected_role_ids and selected_role:
legacy_role = Role.query.filter_by(slug=selected_role, is_active=True).first()
selected_role_ids = [legacy_role.id] if legacy_role else []
user.email = request.form.get('email')
user.role = role
user.is_active = request.form.get('is_active') == 'on'
new_password = request.form.get('new_password')
@ -126,30 +125,23 @@ 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))
replace_user_roles(user, selected_role_ids, assigned_by=current_user)
db.session.commit()
flash('Utilisateur modifié avec succès.', 'success')
return redirect(url_for('admin.users'))
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)
roles_list = Role.query.filter_by(is_active=True).order_by(Role.name).all()
selected_roles = {link.role_id for link in user.role_links if link.role and link.role.is_active}
return render_template('admin/user_form.html', user=user, roles_list=roles_list, selected_roles=selected_roles)
@admin_bp.route('/permissions')
@login_required
@admin_required
def permissions():
"""Liste des permissions."""
from ..models.user import User
users_by_role = {role: [] for role in ROLES}
for user in User.query.order_by(User.full_name).all():
users_by_role[canonical_role(user.role)].append(user)
return render_template(
'admin/permissions.html', roles=ROLES, role_labels=ROLE_LABELS,
users_by_role=users_by_role, permissions_by_role=PERMISSIONS_BY_ROLE,
)
"""Vue de synthèse des rôles et de leurs permissions DB."""
roles_list = Role.query.order_by(Role.is_system.desc(), Role.name).all()
return render_template('admin/permissions.html', roles_list=roles_list)
@admin_bp.route('/roles', methods=['GET', 'POST'])
@ -214,10 +206,7 @@ def user_access(user_id):
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))
replace_user_roles(user, selected_roles, assigned_by=current_user)
# 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)
@ -225,8 +214,6 @@ def user_access(user_id):
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'))
@ -250,15 +237,15 @@ def audit_logs():
@login_required
@admin_required
def update_user_permissions(id):
"""Change le rôle d'un utilisateur (les permissions sont définies en code)."""
"""Compatibilité : remplace l'ensemble des rôles sélectionnés."""
user = User.query.get_or_404(id)
role = request.form.get('role')
if role not in ROLES:
flash('Rôle invalide.', 'danger')
return redirect(url_for('admin.permissions'))
user.role = role
role_ids = request.form.getlist('role_ids')
if not role_ids and request.form.get('role'):
role = Role.query.filter_by(slug=request.form.get('role'), is_active=True).first()
role_ids = [role.id] if role else []
replace_user_roles(user, role_ids, assigned_by=current_user)
db.session.commit()
flash(f'Rôle de {user.username} mis à jour.', 'success')
flash(f'Rôles de {user.username} mis à jour.', 'success')
return redirect(url_for('admin.permissions'))

View file

@ -8,6 +8,9 @@ from ...extensions import db
from ..models.user import User
from ..forms import LoginForm, UserForm, PasswordForm
from ...constants import ROLES
from ..models.rbac import Role
from ..services.rbac import replace_user_roles
from ..authorization import has_permission
auth_bp = Blueprint('auth', __name__)
@ -49,7 +52,7 @@ def logout():
@login_required
def list_users():
"""Liste des utilisateurs."""
if not current_user.is_admin():
if not has_permission('user.view', current_user):
flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index'))
@ -61,12 +64,13 @@ def list_users():
@login_required
def create_user():
"""Créer un utilisateur."""
if not current_user.is_admin():
if not has_permission('user.create', current_user):
flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index'))
form = UserForm()
if form.validate_on_submit():
selected_role_slugs = request.form.getlist('role_ids') or ([form.role.data] if form.role.data else [])
user = User(
username=form.username.data,
email=form.email.data,
@ -80,11 +84,14 @@ def create_user():
user.set_password(form.password.data)
db.session.add(user)
db.session.flush()
selected_roles = Role.query.filter(Role.slug.in_(selected_role_slugs), Role.is_active.is_(True)).all() if selected_role_slugs else []
replace_user_roles(user, [role.id for role in selected_roles], assigned_by=current_user)
db.session.commit()
flash(f"Utilisateur {user.full_name} créé.", "success")
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title="Nouvel utilisateur")
return render_template('auth/user_form.html', form=form, title="Nouvel utilisateur", roles_list=Role.query.filter_by(is_active=True).order_by(Role.name).all())
@auth_bp.route('/profile')
@ -98,7 +105,7 @@ def profile():
@login_required
def edit_user(id):
"""Modifier un utilisateur."""
if not current_user.is_admin():
if not has_permission('user.edit', current_user):
flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index'))
@ -106,20 +113,21 @@ def edit_user(id):
form = UserForm(obj=user)
if form.validate_on_submit():
selected_role_slugs = request.form.getlist('role_ids') or ([form.role.data] if form.role.data else [])
user.username = form.username.data
user.email = form.email.data
user.full_name = form.full_name.data
user.role = form.role.data
user.is_active = form.is_active.data
if form.password.data:
user.set_password(form.password.data)
selected_roles = Role.query.filter(Role.slug.in_(selected_role_slugs), Role.is_active.is_(True)).all() if selected_role_slugs else []
replace_user_roles(user, [role.id for role in selected_roles], assigned_by=current_user)
db.session.commit()
flash(f"Utilisateur {user.full_name} modifié.", "success")
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title="Modifier l'utilisateur")
return render_template('auth/user_form.html', form=form, title="Modifier l'utilisateur", roles_list=Role.query.filter_by(is_active=True).order_by(Role.name).all())
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@ -144,7 +152,7 @@ def change_own_password():
@login_required
def delete_user(id):
"""Supprimer un utilisateur."""
if not current_user.is_admin():
if not has_permission('user.delete', current_user):
flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index'))
@ -163,7 +171,7 @@ def delete_user(id):
@login_required
def change_user_password(id):
"""Changer le mot de passe d'un utilisateur (admin only)."""
if not current_user.is_admin():
if not has_permission('user.edit', current_user):
flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index'))

View file

@ -14,6 +14,9 @@ from ..models.user import User
from ..models.college import College, Building, Zone, Room
from ..models.equipment import EquipmentCategory
from ..models.maintenance import Lot
from ..models.rbac import Role
from ..services.rbac import replace_user_roles
from ..authorization import has_permission
from ..models.planning import WorkSchedule, CollegeClosure, ClosureWorkDay
from ..setup_catalog import EQUIPMENT_CATEGORIES, LOTS, grouped_catalog
@ -256,7 +259,6 @@ def api_save_step(step):
user = User.query.filter_by(username=username).first()
if user:
user.set_password(password)
user.role = 'admin'
user.is_active = True
else:
user = User(
@ -266,7 +268,12 @@ def api_save_step(step):
is_active=True
)
user.set_password(password)
db.session.add(user)
db.session.add(user)
db.session.flush()
admin_role = Role.query.filter_by(slug='admin', is_active=True).first()
if admin_role:
replace_user_roles(user, [admin_role.id])
progress.admin_username = username
progress.step = 2
@ -629,7 +636,7 @@ def api_test_pronote():
@login_required
def admin_panel():
"""Panneau d'administration."""
if not current_user.is_admin():
if not has_permission('system.configure', current_user):
abort(403)
from ..models.user import User
from ..models.college import Building, Room

View file

@ -0,0 +1,45 @@
"""Services transactionnels pour la gestion des rôles RBAC.
Les fonctions de ce module ne valident ni ne commitent la transaction appelante.
Cela permet aux routes d'enregistrer simultanément l'utilisateur et ses accès.
"""
from ...extensions import db
from ..models.rbac import Role, UserRole
def replace_user_roles(user, role_ids, assigned_by=None):
"""Remplace l'ensemble des rôles actifs d'un utilisateur.
Les liens déjà sélectionnés sont conservés (et donc leur traçabilité), les
liens absents sont retirés et les nouveaux sont ajoutés sans doublon.
``role_ids`` peut être vide. Les rôles inactifs ou inconnus sont ignorés.
La fonction travaille dans la transaction courante et ne committe jamais.
"""
normalized_ids = set()
for value in role_ids or ():
try:
normalized_ids.add(int(value))
except (TypeError, ValueError):
continue
active_roles = {
role.id: role
for role in Role.query.filter(Role.id.in_(normalized_ids), Role.is_active.is_(True)).all()
} if normalized_ids else {}
selected_ids = set(active_roles)
existing = {link.role_id: link for link in list(user.role_links)}
for role_id, link in existing.items():
if role_id not in selected_ids:
db.session.delete(link)
for role_id in selected_ids - set(existing):
db.session.add(UserRole(
user_id=user.id,
role_id=role_id,
assigned_by_id=getattr(assigned_by, "id", assigned_by),
))
return [active_roles[role_id] for role_id in sorted(selected_ids)]

View file

@ -3,6 +3,7 @@ from functools import wraps
from flask import abort
from flask_login import current_user, login_required
from .authorization import has_permission
def system_admin_required(view):
@ -10,7 +11,7 @@ def system_admin_required(view):
@wraps(view)
@login_required
def wrapped(*args, **kwargs):
if not current_user.is_admin():
if not has_permission("system.configure", current_user):
abort(403)
return view(*args, **kwargs)

View file

@ -60,11 +60,11 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
</div>
<div class="col-md-6">
<strong>Permission code:edit :</strong>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<span class="badge bg-success">✅ Accordée</span>
{% else %}
<span class="badge bg-danger">❌ Refusée</span>

View file

@ -52,11 +52,11 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
</div>
<div class="col-md-6">
<strong>Permission code:edit :</strong>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<span class="badge bg-success">✅ Accordée</span>
{% else %}
<span class="badge bg-danger">❌ Refusée</span>

View file

@ -1,232 +0,0 @@
"""Gestion des permissions et accès par rôle."""
"""Gestion des permissions et accès par rôle."""
from functools import wraps
from flask import abort, current_app
from flask_login import current_user
import os
class Permission:
"""Définition des permissions de l'application."""
# Permissions de base
VIEW_DASHBOARD = 'dashboard:view'
CREATE_INTERVENTION = 'intervention:create'
VIEW_INTERVENTIONS = 'intervention:view'
EDIT_INTERVENTIONS = 'intervention:edit'
DELETE_INTERVENTIONS = 'intervention:delete'
# Permissions équipements
VIEW_EQUIPMENTS = 'equipment:view'
CREATE_EQUIPMENTS = 'equipment:create'
EDIT_EQUIPMENTS = 'equipment:edit'
DELETE_EQUIPMENTS = 'equipment:delete'
# Permissions planning
VIEW_PLANNING = 'planning:view'
CREATE_PLANNING = 'planning:create'
EDIT_PLANNING = 'planning:edit'
# Permissions ENT
VIEW_ENT = 'ent:view'
CONFIG_ENT = 'ent:config'
MANAGE_ENT_MESSAGES = 'ent:manage'
# Permissions administratives
VIEW_USERS = 'user:view'
CREATE_USERS = 'user:create'
EDIT_USERS = 'user:edit'
DELETE_USERS = 'user:delete'
VIEW_ADMIN = 'admin:view'
# Permission de modification du code (admin seulement)
EDIT_CODE = 'code:edit'
# Permissions entreprises et pièces
VIEW_COMPANIES = 'company:view'
CREATE_COMPANIES = 'company:create'
EDIT_COMPANIES = 'company:edit'
DELETE_COMPANIES = 'company:delete'
VIEW_PARTS = 'part:view'
CREATE_PARTS = 'part:create'
EDIT_PARTS = 'part:edit'
DELETE_PARTS = 'part:delete'
class RolePermission:
"""Définition des permissions par rôle."""
# Rôle administrateur (accès à tout)
ADMIN = {
Permission.VIEW_DASHBOARD,
Permission.CREATE_INTERVENTION,
Permission.VIEW_INTERVENTIONS,
Permission.EDIT_INTERVENTIONS,
Permission.DELETE_INTERVENTIONS,
Permission.VIEW_EQUIPMENTS,
Permission.CREATE_EQUIPMENTS,
Permission.EDIT_EQUIPMENTS,
Permission.DELETE_EQUIPMENTS,
Permission.VIEW_PLANNING,
Permission.CREATE_PLANNING,
Permission.EDIT_PLANNING,
Permission.VIEW_ENT,
Permission.CONFIG_ENT,
Permission.MANAGE_ENT_MESSAGES,
Permission.VIEW_USERS,
Permission.CREATE_USERS,
Permission.EDIT_USERS,
Permission.DELETE_USERS,
Permission.VIEW_ADMIN,
Permission.VIEW_COMPANIES,
Permission.CREATE_COMPANIES,
Permission.EDIT_COMPANIES,
Permission.DELETE_COMPANIES,
Permission.VIEW_PARTS,
Permission.CREATE_PARTS,
Permission.EDIT_PARTS,
Permission.DELETE_PARTS,
Permission.EDIT_CODE, # Permission de modification du code
}
# Rôle technicien (peut voir et gérer les interventions, voir les équipements)
TECHNICIAN = {
Permission.VIEW_DASHBOARD,
Permission.CREATE_INTERVENTION,
Permission.VIEW_INTERVENTIONS,
Permission.EDIT_INTERVENTIONS,
Permission.VIEW_EQUIPMENTS,
Permission.VIEW_PLANNING,
Permission.VIEW_ENT,
}
# Rôle chef d'établissement (peut voir et gérer les interventions, planning, voir les données)
CHEF = {
Permission.VIEW_DASHBOARD,
Permission.CREATE_INTERVENTION,
Permission.VIEW_INTERVENTIONS,
Permission.EDIT_INTERVENTIONS,
Permission.VIEW_EQUIPMENTS,
Permission.VIEW_PLANNING,
Permission.CREATE_PLANNING,
Permission.VIEW_ENT,
}
# Rôle demandeur (peut voir le dashboard, créer des interventions, voir les équipements)
DEMANDEUR = {
Permission.VIEW_DASHBOARD,
Permission.CREATE_INTERVENTION,
Permission.VIEW_INTERVENTIONS,
Permission.VIEW_EQUIPMENTS,
Permission.VIEW_PLANNING,
}
def get_role_permissions(role):
"""Retourne les permissions pour un rôle donné."""
role_permissions_map = {
'admin': RolePermission.ADMIN,
'technicien': RolePermission.TECHNICIAN,
'chef': RolePermission.CHEF,
'demandeur': RolePermission.DEMANDEUR,
}
return role_permissions_map.get(role, set())
def permission_required(permission):
"""Décorateur pour vérifier une permission spécifique."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role == 'admin':
return f(*args, **kwargs)
user_permissions = get_role_permissions(current_user.role)
if permission not in user_permissions:
current_app.logger.warning(
f"User {current_user.username} (role: {current_user.role}) "
f"tried to access {permission} which is not allowed"
)
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def any_permission_required(*permissions):
"""Décorateur pour vérifier qu'au moins une permission parmi les listées est présente."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role == 'admin':
return f(*args, **kwargs)
user_permissions = get_role_permissions(current_user.role)
# Vérifier si l'utilisateur a au moins une des permissions requises
has_permission = any(perm in user_permissions for perm in permissions)
if not has_permission:
current_app.logger.warning(
f"User {current_user.username} (role: {current_user.role}) "
f"tried to access {permissions} but none are allowed"
)
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def role_required(role):
"""Décorateur pour vérifier le rôle exact."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role != role:
current_app.logger.warning(
f"User {current_user.username} (role: {current_user.role}) "
f"tried to access endpoint requiring {role} role"
)
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def has_permission(permission):
"""Macro template pour vérifier une permission dans les templates."""
if not current_user.is_authenticated:
return False
if current_user.role == 'admin':
return True
user_permissions = get_role_permissions(current_user.role)
return permission in user_permissions
def get_user_permissions(user=None):
"""Retourne les permissions d'un utilisateur (ou de l'utilisateur courant)."""
if user is None:
if not current_user.is_authenticated:
return set()
user = current_user
if user.role == 'admin':
return RolePermission.ADMIN
return get_role_permissions(user.role)

View file

@ -37,20 +37,11 @@ def requires_permission(user, permission):
if not user:
return False
# Vérifier les permissions basées sur le rôle
if user.role == 'admin':
return True
if user.role == 'chef' and permission in ['can_create_interventions', 'can_view_statistics']:
return True
if user.role == 'technician' and permission in ['can_create_interventions', 'can_view_equipments']:
return True
if user.role == 'requester' and permission == 'can_create_interventions':
return True
return False
# Compatibilité pour les anciens appelants : la décision est déléguée au
# RBAC SQL centralisé et ne lit plus User.role.
from app_new.core.authorization import has_permission
normalized = permission.replace(':', '.').replace('_', '.')
return has_permission(normalized, user)
def log_action(user, action, data=None):

View file

@ -62,7 +62,7 @@
</div>
{% endif %}
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<div class="mt-4 p-3 bg-light rounded">
<h6>📊 Statistiques</h6>
<div class="row text-center">

View file

@ -18,36 +18,34 @@
</div>
<div class="row g-3">
{% for role_key, role_name in roles.items() %}
{% for role in roles_list %}
<div class="col-xl-4 col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between">
<strong>{{ role_name }}</strong>
<span class="badge bg-secondary">{{ users_by_role[role_key]|length }} compte(s)</span>
<strong>{{ role.name }}</strong>
<span class="badge bg-secondary">{{ role.user_links|length }} compte(s)</span>
</div>
<div class="card-body">
<h2 class="h6">Permissions</h2>
{% if '*' in permissions_by_role[role_key] %}
{% if role.permission_links|selectattr('permission.code', 'equalto', '*')|list %}
<span class="badge bg-danger">Accès complet</span>
{% else %}
<div class="d-flex flex-wrap gap-1 mb-3">
{% for permission in permissions_by_role[role_key]|sort %}
<span class="badge text-bg-light border">{{ permission }}</span>
{% for link in role.permission_links if link.effect == 'allow' and link.permission.is_active %}
<span class="badge text-bg-light border">{{ link.permission.code }}</span>
{% endfor %}
</div>
{% endif %}
{% if users_by_role[role_key] %}
{% if role.user_links %}
<hr>
{% for user in users_by_role[role_key] %}
{% for link in role.user_links %}{% set user = link.user %}
<form method="post" action="{{ url_for('admin.update_user_permissions', id=user.id) }}" class="row g-2 align-items-center mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col"><span class="small">{{ user.full_name or user.username }}</span></div>
<div class="col-auto">
<select name="role" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
{% for candidate, label in roles.items() %}
<option value="{{ candidate }}" {% if candidate == role_key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
<select name="role_ids" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
<option value="{{ role.id }}" selected>{{ role.name }}</option>
</select>
</div>
<div class="col-auto"><button class="btn btn-sm btn-outline-primary" type="submit">Appliquer</button></div>

View file

@ -39,12 +39,16 @@
</div>
<div class="mb-3">
<label for="role" class="form-label">Rôle *</label>
<select class="form-select" id="role" name="role" required>
{% for role_key, role_name in roles.items() %}
<option value="{{ role_key }}" {{ 'selected' if user and user.canonical_role == role_key else '' }}>{{ role_name }}</option>
{% endfor %}
</select>
<label class="form-label">Rôles</label>
<div class="row g-2">
{% for role in roles_list|default([]) %}
<div class="col-md-6"><label class="border rounded p-2 d-block">
<input type="checkbox" name="role_ids" value="{{ role.id }}" {% if role.id in selected_roles|default([]) %}checked{% endif %}>
{{ role.name }} <small class="text-muted">({{ role.slug }})</small>
</label></div>
{% else %}<div class="text-muted">Aucun rôle RBAC actif.</div>{% endfor %}
</div>
<small class="text-muted">Un utilisateur peut posséder plusieurs rôles. Le champ historique User.role nest pas utilisé pour la sécurité.</small>
</div>
{% if not user %}

View file

@ -1,205 +0,0 @@
{% extends "base.html" %}
{% block title %}Permissions utilisateur - {{ user.full_name }} - GMAO Collège{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h1 class="h3">
<i class="bi bi-shield-check"></i> Permissions de {{ user.full_name }}
</h1>
<p class="text-muted">
Rôle actuel : <span class="badge {% if user.role == 'admin' %}bg-danger{% elif user.role == 'chef' %}bg-warning{% elif user.role == 'technicien' %}bg-success{% else %}bg-info{% endif %}">{{ user.role|title }}</span>
</p>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Retour
</a>
</div>
</div>
<!-- Informations utilisateur -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Informations</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<td><strong>Nom d'utilisateur:</strong></td>
<td>{{ user.username }}</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>{{ user.email or 'Non défini' }}</td>
</tr>
<tr>
<td><strong>Statut:</strong></td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Actif</span>
{% else %}
<span class="badge bg-secondary">Inactif</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Date de création:</strong></td>
<td>{{ user.created_at|datetime_fmt if user.created_at else 'Non défini' }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Permissions actuelles</h5>
</div>
<div class="card-body">
<div class="row g-2">
{% for perm in user_permissions %}
<div class="col-6">
<div class="alert alert-success small mb-1">
<i class="bi bi-check-circle"></i> {{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}
</div>
</div>
{% endfor %}
{% if not user_permissions %}
<div class="col-12">
<div class="alert alert-warning small">
<i class="bi bi-exclamation-circle"></i> Aucune permission définie
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modifier le rôle -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Modifier le rôle</h5>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.update_user_permissions', user_id=user.id) }}">
<div class="row">
<div class="col-md-6">
<label for="role" class="form-label">Nouveau rôle</label>
<select class="form-select" id="role" name="role" required>
<option value="">Sélectionner un rôle</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>
Administrateur (accès à tout)
</option>
<option value="chef" {% if user.role == 'chef' %}selected{% endif %}>
Chef d'établissement (gestion interventions, planning)
</option>
<option value="technicien" {% if user.role == 'technicien' %}selected{% endif %}>
Technicien (gestions interventions, équipements)
</option>
<option value="demandeur" {% if user.role == 'demandeur' %}selected{% endif %}>
Demandeur (création interventions, consultation)
</option>
</select>
<div class="form-text">
Le changement de rôle mettra à jour automatiquement les permissions de l'utilisateur.
</div>
</div>
<div class="col-md-6">
<label class="form-label">Permissions correspondantes</label>
<div class="border rounded p-3" style="height: 200px; overflow-y: auto;">
{% for perm in all_permissions %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox"
id="preview_{{ perm.replace('.', '_') }}"
{% if perm in user_permissions %}checked{% endif %} disabled>
<label class="form-check-label small" for="preview_{{ perm.replace('.', '_') }}">
{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="text-end mt-3">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">Annuler</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Enregistrer les modifications
</button>
</div>
</form>
</div>
</div>
<!-- Comparaison des rôles -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Comparaison des permissions par rôle</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Permission</th>
<th>Admin</th>
<th>Chef</th>
<th>Technicien</th>
<th>Demandeur</th>
<th>Actuel</th>
</tr>
</thead>
<tbody>
{% for perm in all_permissions %}
<tr>
<td class="small">{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}</td>
<td class="text-center">
{% if perm in get_role_permissions('admin') %}
<i class="bi bi-check-circle text-success"></i>
{% else %}
<i class="bi bi-x-circle text-muted"></i>
{% endif %}
</td>
<td class="text-center">
{% if perm in get_role_permissions('chef') %}
<i class="bi bi-check-circle text-success"></i>
{% else %}
<i class="bi bi-x-circle text-muted"></i>
{% endif %}
</td>
<td class="text-center">
{% if perm in get_role_permissions('technicien') %}
<i class="bi bi-check-circle text-success"></i>
{% else %}
<i class="bi bi-x-circle text-muted"></i>
{% endif %}
</td>
<td class="text-center">
{% if perm in get_role_permissions('demandeur') %}
<i class="bi bi-check-circle text-success"></i>
{% else %}
<i class="bi bi-x-circle text-muted"></i>
{% endif %}
</td>
<td class="text-center">
{% if perm in user_permissions %}
<i class="bi bi-check-circle text-primary"></i>
{% else %}
<i class="bi bi-x-circle text-muted"></i>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -29,8 +29,8 @@
<td>{{ user.full_name or user.username }}</td>
<td>{{ user.email or '-' }}</td>
<td>
<span class="badge bg-{{ 'danger' if user.role == 'super_admin' else 'primary' if user.role == 'admin' else 'secondary' }}">
{{ user.role }}
<span class="badge bg-{{ 'danger' if user.is_admin() else 'secondary' }}">
{{ user.role_label }}
</span>
</td>
<td>

View file

@ -54,12 +54,10 @@
<td>{{ user.email or 'Non défini' }}</td>
<td>
<span class="badge
{% if user.role == 'admin' %}bg-danger
{% elif user.role == 'chef' %}bg-warning
{% elif user.role == 'technicien' %}bg-success
{% elif user.role == 'demandeur' %}bg-info
{% if user.is_admin() %}bg-danger
{% elif user.has_permission('patrimoine.manage') %}bg-success
{% else %}bg-secondary{% endif %}">
{{ user.role|title }}
{{ user.role_label }}
</span>
</td>
<td>
@ -71,7 +69,7 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('admin.user_permissions', user_id=user.id) }}"
<a href="{{ url_for('admin.user_access', user_id=user.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-shield-check"></i>
</a>

View file

@ -31,9 +31,9 @@
<dt class="col-sm-4">Rôle</dt>
<dd class="col-sm-8">
{% if user.role == 'admin' %}
{% if user.is_admin() %}
<span class="badge bg-danger">Administrateur</span>
{% elif user.role == 'tech' %}
{% elif user.has_permission('patrimoine.manage') %}
<span class="badge bg-primary">Technicien</span>
{% else %}
<span class="badge bg-secondary">Utilisateur</span>

View file

@ -34,8 +34,16 @@
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.role.label(class="form-label") }}
{{ form.role(class="form-select") }}
<label class="form-label">Rôles</label>
<input type="hidden" name="role" value="{{ form.role.data or 'demandeur' }}">
<div class="row g-1">
{% for role in roles_list|default([]) %}
<div class="col-6"><label class="border rounded p-1 d-block small">
<input type="checkbox" name="role_ids" value="{{ role.slug }}" {% if form.role.data == role.slug %}checked{% endif %}> {{ role.name }}
</label></div>
{% endfor %}
</div>
<small class="text-muted">Plusieurs rôles peuvent être sélectionnés.</small>
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check">

View file

@ -19,11 +19,9 @@
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
{% if user.role == 'admin' %}<span class="badge bg-danger">Administrateur</span>
{% elif user.role == 'chef' %}<span class="badge bg-warning text-dark">Chef d'établissement</span>
{% elif user.role == 'technicien' %}<span class="badge bg-primary">Technicien</span>
{% elif user.role == 'demandeur' %}<span class="badge bg-secondary">Demandeur</span>
{% else %}<span class="badge bg-secondary">{{ user.role }}</span>{% endif %}
{% if user.is_admin() %}<span class="badge bg-danger">Administrateur</span>
{% elif user.has_permission('patrimoine.manage') %}<span class="badge bg-primary">{{ user.role_label }}</span>
{% else %}<span class="badge bg-secondary">{{ user.role_label }}</span>{% endif %}
</td>
<td>
{% if user.is_active %}<span class="badge bg-success">Actif</span>

View file

@ -60,11 +60,11 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
</div>
<div class="col-md-6">
<strong>Permission code:edit :</strong>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<span class="badge bg-success">✅ Accordée</span>
{% else %}
<span class="badge bg-danger">❌ Refusée</span>

View file

@ -79,7 +79,7 @@
</div>
<!-- Mode Debug (optionnel) -->
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<div class="mb-3 p-3 bg-warning-subtle rounded">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="debug_mode" id="debug_mode" value="1"

View file

@ -52,11 +52,11 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
</div>
<div class="col-md-6">
<strong>Permission code:edit :</strong>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<span class="badge bg-success">✅ Accordée</span>
{% else %}
<span class="badge bg-danger">❌ Refusée</span>

View file

@ -60,7 +60,7 @@
</div>
{% endif %}
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<div class="mt-4 p-3 bg-light rounded">
<h6>📊 Statistiques</h6>
<div class="row text-center">

View file

@ -7,7 +7,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-mortarboard"></i> {{ training.name }}</h1>
<div>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<a href="{{ url_for('trainings.edit', id=training.id) }}" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i> Modifier</a>
{% endif %}
<a href="{{ url_for('trainings.index') }}" class="btn btn-secondary">Retour</a>

View file

@ -6,7 +6,7 @@
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-mortarboard"></i> Formations</h1>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<a href="{{ url_for('trainings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Créer une formation</a>
{% endif %}
</div>

View file

@ -7,7 +7,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-mortarboard"></i> {{ training.name }}</h1>
<div>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<a href="{{ url_for('trainings.edit', id=training.id) }}" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i> Modifier</a>
{% endif %}
<a href="{{ url_for('trainings.index') }}" class="btn btn-secondary">Retour</a>

View file

@ -6,7 +6,7 @@
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-mortarboard"></i> Formations</h1>
{% if current_user.role == 'admin' %}
{% if current_user.is_admin() %}
<a href="{{ url_for('trainings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Créer une formation</a>
{% endif %}
</div>

View file

@ -0,0 +1,69 @@
"""Garantit le rattachement RBAC explicite des utilisateurs existants.
Cette migration termine la bascule depuis ``users.role`` vers les relations
RBAC. Elle est idempotente et ne supprime aucune donnée historique.
"""
from alembic import op
import sqlalchemy as sa
revision = "9e39f0a1b2c3"
down_revision = "9d28e9f0a1b2"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
bind.execute(sa.text(
"""INSERT INTO roles
(slug, name, description, is_system, is_active, created_at, updated_at)
SELECT 'admin', 'Administrateur système',
'Rôle système donnant toutes les permissions RBAC', 1, 1, NOW(), NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'admin')"""
))
bind.execute(sa.text("UPDATE roles SET is_active = 1, is_system = 1 WHERE slug = 'admin'"))
bind.execute(sa.text(
"""INSERT INTO permissions
(code, name, module, action, description, is_active)
SELECT '*', 'Accès complet', 'system', 'all',
'Accès complet accordé au rôle administrateur', 1
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code = '*')"""
))
bind.execute(sa.text("UPDATE permissions SET is_active = 1 WHERE code = '*'"))
bind.execute(sa.text(
"""INSERT INTO role_permissions (role_id, permission_id, effect)
SELECT r.id, p.id, 'allow'
FROM roles r CROSS JOIN permissions p
WHERE r.slug = 'admin' AND p.code = '*'
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)"""
))
# Tous les comptes existants reçoivent explicitement admin. Le champ
# users.role est conservé pour compatibilité d'affichage uniquement.
bind.execute(sa.text(
"""INSERT INTO user_roles (user_id, role_id, assigned_at)
SELECT u.id, r.id, NOW()
FROM users u CROSS JOIN roles r
WHERE r.slug = 'admin'
AND NOT EXISTS (
SELECT 1 FROM user_roles ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
)"""
))
def downgrade():
# Ne pas retirer les rattachements administrateurs : ils peuvent avoir été
# modifiés après migration et leur suppression serait destructive.
pass

View file

@ -32,6 +32,24 @@ def app():
with app.app_context():
db.drop_all()
db.create_all()
# Le runtime ne lit plus User.role. Le bootstrap de test reproduit la
# migration RBAC en créant explicitement les rôles et leurs permissions.
from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS
from app_new.core.models.rbac import Role, Permission, RolePermission
permissions_by_code = {}
for slug, codes in PERMISSIONS_BY_ROLE.items():
role = Role(slug=slug, name=ROLE_LABELS.get(slug, slug), is_system=True)
db.session.add(role)
db.session.flush()
for code in codes:
permission = permissions_by_code.get(code)
if permission is None:
permission = Permission(code=code, name=code, module=code.split('.', 1)[0], action=code.split('.', 1)[1] if '.' in code else 'all')
db.session.add(permission)
db.session.flush()
permissions_by_code[code] = permission
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect='allow'))
db.session.commit()
yield app
with app.app_context():
db.session.remove()
@ -64,6 +82,10 @@ def admin_user(app):
)
user.set_password(password)
db.session.add(user)
db.session.flush()
from app_new.core.models.rbac import Role, UserRole
admin_role = Role.query.filter_by(slug='admin', is_active=True).first()
db.session.add(UserRole(user_id=user.id, role_id=admin_role.id))
db.session.commit()
user_id = user.id
yield {'id': user_id, 'username': username, 'password': password}

View file

@ -1,6 +1,7 @@
import factory
from app_new.core.models.user import User
from app_new import db
from app_new.core.models.rbac import Role, UserRole
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
@ -16,6 +17,15 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
role = 'technicien'
is_active = True
@classmethod
def _create(cls, model_class, *args, **kwargs):
user = super()._create(model_class, *args, **kwargs)
role = Role.query.filter_by(slug=user.role, is_active=True).first()
if role and not UserRole.query.filter_by(user_id=user.id, role_id=role.id).first():
db.session.add(UserRole(user_id=user.id, role_id=role.id))
db.session.commit()
return user
class AdminFactory(UserFactory):
role = 'admin'

View file

@ -6,6 +6,7 @@ import pytest
from app_new import db
from app_new.core.authorization import canonical_role
from app_new.core.models.user import User
from app_new.core.models.rbac import Role, UserRole
def _login_as(client, app, role):
@ -20,6 +21,9 @@ def _login_as(client, app, role):
)
user.set_password(password)
db.session.add(user)
db.session.flush()
rbac_role = Role.query.filter_by(slug=role, is_active=True).first()
db.session.add(UserRole(user_id=user.id, role_id=rbac_role.id))
db.session.commit()
username = user.username
response = client.post("/auth/login", data={"username": username, "password": password})

View file

@ -1,7 +1,109 @@
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.models import User, Role, Permission, RolePermission, UserPermission, UserRole
from app_new.core.authorization import has_permission
from app_new.core.services.rbac import replace_user_roles
def _role_with_permissions(slug, permissions):
role = Role(slug=slug, name=slug, is_system=False, is_active=True)
db.session.add(role)
db.session.flush()
for code in permissions:
permission = Permission.query.filter_by(code=code).first()
if permission is None:
permission = Permission(code=code, name=code, module=code.split('.', 1)[0], action=code.split('.', 1)[1])
db.session.add(permission)
db.session.flush()
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect='allow'))
return role
def _user():
user = User(username='multi_' + uuid4().hex[:10], email=uuid4().hex + '@test.local', full_name='Multi', role='demandeur')
user.set_password('test-password-strong')
db.session.add(user)
db.session.flush()
return user
def test_multiple_roles_union_and_removal(app):
with app.app_context():
role_a = _role_with_permissions('role_a_' + uuid4().hex[:8], ['patrimoine.view'])
role_b = _role_with_permissions('role_b_' + uuid4().hex[:8], ['prevention.view'])
role_c = _role_with_permissions('role_c_' + uuid4().hex[:8], ['stock.manage'])
user = _user()
replace_user_roles(user, [role_a.id, role_b.id, role_c.id])
db.session.commit()
assert has_permission('patrimoine.view', user)
assert has_permission('prevention.view', user)
assert has_permission('stock.manage', user)
replace_user_roles(user, [role_b.id, role_c.id])
db.session.commit()
assert not has_permission('patrimoine.view', user)
assert has_permission('prevention.view', user)
assert has_permission('stock.manage', user)
def test_admin_removed_keeps_other_roles_without_wildcard(app):
with app.app_context():
admin = Role.query.filter_by(slug='admin', is_active=True).first()
tech = Role.query.filter_by(slug='technicien', is_active=True).first()
prevention = Role.query.filter_by(slug='assistant_prevention', is_active=True).first()
user = _user()
replace_user_roles(user, [admin.id, tech.id, prevention.id])
db.session.commit()
assert has_permission('*', user)
replace_user_roles(user, [tech.id, prevention.id])
db.session.commit()
assert not has_permission('*', user)
assert has_permission('patrimoine.view', user)
assert has_permission('prevention.view', user)
def test_individual_deny_overrides_all_roles_and_allow_adds(app):
with app.app_context():
role_a = _role_with_permissions('deny_a_' + uuid4().hex[:8], ['stock.manage'])
role_b = _role_with_permissions('deny_b_' + uuid4().hex[:8], ['stock.view'])
user = _user()
replace_user_roles(user, [role_a.id, role_b.id])
db.session.commit()
permission = Permission.query.filter_by(code='stock.manage').first()
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect='deny'))
extra = Permission(code='audit.test.view_' + uuid4().hex[:6], name='audit test', module='audit', action='view')
db.session.add(extra)
db.session.flush()
db.session.add(UserPermission(user_id=user.id, permission_id=extra.id, effect='allow'))
db.session.commit()
assert not has_permission('stock.manage', user)
assert has_permission(extra.code, user)
def test_inactive_role_is_ignored_and_no_duplicate_links(app):
with app.app_context():
code = 'system.test.view_' + uuid4().hex[:6]
role = _role_with_permissions('inactive_' + uuid4().hex[:8], [code])
role.is_active = False
user = _user()
replace_user_roles(user, [role.id, role.id])
replace_user_roles(user, [role.id])
db.session.commit()
assert not has_permission(code, user)
assert UserRole.query.filter_by(user_id=user.id, role_id=role.id).count() == 0
def test_rbac_errors_fail_closed(app, monkeypatch):
with app.app_context():
class BrokenUser:
is_authenticated = True
id = 999999
@property
def role_links(self):
raise RuntimeError('database unavailable')
@property
def permission_links(self):
return []
assert not has_permission('dashboard.view', BrokenUser())
def test_custom_role_permission_and_override(app):
with app.app_context():
@ -12,7 +114,8 @@ def test_custom_role_permission_and_override(app):
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()
db.session.add(user); db.session.flush()
db.session.add(UserRole(user_id=user.id, role_id=role.id)); 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"))
@ -23,4 +126,3 @@ def test_admin_rbac_page_requires_authentication(client):
response = client.get("/admin/roles")
assert response.status_code == 302
assert "/auth/login" in response.location

View file

@ -17,9 +17,11 @@ def test_login_post_without_csrf_is_rejected_when_enabled(client, app):
def test_mutating_routes_reject_get(client):
assert client.get("/equipments/1/status/hs").status_code == 405
assert client.get("/pronote/sync-salles").status_code == 405
assert client.get("/pronote/disconnect").status_code == 405
assert client.get("/outlook/api/sync-folders/1").status_code == 405
assert client.get("/outlook/api/sync-mails/1").status_code == 405
assert client.get("/outlook/api/sync-attachments/inconnu").status_code == 405
# Le garde global deny-by-default traite d'abord l'anonyme : une route
# privée redirige vers la connexion avant le contrôle de méthode HTTP.
assert client.get("/equipments/1/status/hs").status_code == 302
assert client.get("/pronote/sync-salles").status_code == 302
assert client.get("/pronote/disconnect").status_code == 302
assert client.get("/outlook/api/sync-folders/1").status_code == 302
assert client.get("/outlook/api/sync-mails/1").status_code == 302
assert client.get("/outlook/api/sync-attachments/inconnu").status_code == 302

View file

@ -10,8 +10,10 @@ def test_user_password_hashing():
assert not user.check_password('mauvais')
def test_user_admin_role():
def test_user_legacy_role_does_not_grant_admin():
user = User(username='admin', role='admin', password_hash='')
assert user.is_admin()
# Le champ historique est conservé uniquement pour compatibilité et ne
# peut plus accorder de droits sans UserRole/RolePermission.
assert not user.is_admin()
user2 = User(username='user', role='user', password_hash='')
assert not user2.is_admin()

View file

@ -0,0 +1,16 @@
from pathlib import Path
from app_new import create_app
def test_production_configuration_is_secure():
app = create_app('production')
assert app.config['DEBUG'] is False
assert app.config['SESSION_COOKIE_SECURE'] is True
assert app.config['WTF_CSRF_ENABLED'] is True
def test_wsgi_uses_production_factory():
source = Path(__file__).resolve().parents[2].joinpath('wsgi.py').read_text()
assert "create_app('production')" in source

View file

@ -15,7 +15,7 @@ load_dotenv('/app/.env.docker')
from app_new import create_app
app = create_app()
app = create_app('production')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5080)