From 785c8aabcbc62238a8312a6f9344be1cd45c6ccd Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 16:46:19 +0000 Subject: [PATCH] Refondre le RBAC multi-roles et securiser les acces --- app_new/api/routes.py | 30 ++- app_new/core/authorization.py | 80 +++--- app_new/core/models/user.py | 26 +- app_new/core/routes/admin.py | 77 +++--- app_new/core/routes/auth.py | 26 +- app_new/core/routes/setup_wizard.py | 13 +- app_new/core/services/rbac.py | 45 ++++ app_new/core/system_security.py | 3 +- app_new/ent/templates/ent/code_editor.html | 6 +- app_new/ent/templates/ent/logs_viewer.html | 6 +- app_new/lib_ext/permissions.py | 232 ------------------ app_new/lib_ext/utils.py | 21 +- .../templates/pronote/planning_index.html | 2 +- app_new/templates/admin/permissions.html | 22 +- app_new/templates/admin/user_form.html | 16 +- app_new/templates/admin/user_permissions.html | 205 ---------------- app_new/templates/admin/users.html | 4 +- app_new/templates/admin/users_list.html | 12 +- app_new/templates/auth/profile.html | 6 +- app_new/templates/auth/user_form.html | 14 +- app_new/templates/auth/users.html | 10 +- app_new/templates/ent/code_editor.html | 6 +- app_new/templates/ent/config.html | 4 +- app_new/templates/ent/logs_viewer.html | 6 +- app_new/templates/pronote/planning_index.html | 2 +- app_new/templates/training/detail.html | 2 +- app_new/templates/training/index.html | 2 +- app_new/training/templates/detail.html | 4 +- app_new/training/templates/index.html | 4 +- .../9e39f0a1b2c3_garantir_roles_admin_rbac.py | 69 ++++++ tests/conftest.py | 22 ++ tests/factories/user.py | 10 + tests/integration/test_authorization.py | 4 + tests/integration/test_rbac.py | 108 +++++++- tests/integration/test_request_security.py | 14 +- tests/unit/test_models.py | 6 +- tests/unit/test_production_config.py | 16 ++ wsgi.py | 4 +- 38 files changed, 506 insertions(+), 633 deletions(-) create mode 100644 app_new/core/services/rbac.py delete mode 100644 app_new/lib_ext/permissions.py delete mode 100644 app_new/templates/admin/user_permissions.html create mode 100644 migrations/versions/9e39f0a1b2c3_garantir_roles_admin_rbac.py create mode 100644 tests/unit/test_production_config.py diff --git a/app_new/api/routes.py b/app_new/api/routes.py index 85c4413..2d17e28 100644 --- a/app_new/api/routes.py +++ b/app_new/api/routes.py @@ -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/') -@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/') -@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 diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py index 00a8d49..07c2d85 100644 --- a/app_new/core/authorization.py +++ b/app_new/core/authorization.py @@ -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) diff --git a/app_new/core/models/user.py b/app_new/core/models/user.py index 81e7404..dc3c309 100644 --- a/app_new/core/models/user.py +++ b/app_new/core/models/user.py @@ -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 diff --git a/app_new/core/routes/admin.py b/app_new/core/routes/admin.py index b5166d5..819215b 100644 --- a/app_new/core/routes/admin.py +++ b/app_new/core/routes/admin.py @@ -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 d’utilisateur 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//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')) diff --git a/app_new/core/routes/auth.py b/app_new/core/routes/auth.py index 8e4884d..da7784a 100644 --- a/app_new/core/routes/auth.py +++ b/app_new/core/routes/auth.py @@ -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')) diff --git a/app_new/core/routes/setup_wizard.py b/app_new/core/routes/setup_wizard.py index f99ac1f..0724cde 100644 --- a/app_new/core/routes/setup_wizard.py +++ b/app_new/core/routes/setup_wizard.py @@ -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 diff --git a/app_new/core/services/rbac.py b/app_new/core/services/rbac.py new file mode 100644 index 0000000..8d26b47 --- /dev/null +++ b/app_new/core/services/rbac.py @@ -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)] + diff --git a/app_new/core/system_security.py b/app_new/core/system_security.py index 6817396..9ad2660 100644 --- a/app_new/core/system_security.py +++ b/app_new/core/system_security.py @@ -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) diff --git a/app_new/ent/templates/ent/code_editor.html b/app_new/ent/templates/ent/code_editor.html index e945078..24d59da 100644 --- a/app_new/ent/templates/ent/code_editor.html +++ b/app_new/ent/templates/ent/code_editor.html @@ -60,11 +60,11 @@
- Utilisateur actuel : {{ current_user.username }} ({{ current_user.role }}) + Utilisateur actuel : {{ current_user.username }} ({{ current_user.role_label }})
Permission code:edit : - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} ✅ Accordée {% else %} ❌ Refusée @@ -130,4 +130,4 @@ window.onload = function() { }; {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/ent/templates/ent/logs_viewer.html b/app_new/ent/templates/ent/logs_viewer.html index cedaee4..556c27b 100644 --- a/app_new/ent/templates/ent/logs_viewer.html +++ b/app_new/ent/templates/ent/logs_viewer.html @@ -52,11 +52,11 @@
- Utilisateur actuel : {{ current_user.username }} ({{ current_user.role }}) + Utilisateur actuel : {{ current_user.username }} ({{ current_user.role_label }})
Permission code:edit : - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} ✅ Accordée {% else %} ❌ Refusée @@ -67,4 +67,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/lib_ext/permissions.py b/app_new/lib_ext/permissions.py deleted file mode 100644 index 051d3cc..0000000 --- a/app_new/lib_ext/permissions.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app_new/lib_ext/utils.py b/app_new/lib_ext/utils.py index 794c9e4..1ea5a37 100644 --- a/app_new/lib_ext/utils.py +++ b/app_new/lib_ext/utils.py @@ -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): @@ -272,4 +263,4 @@ def priority_badge_class(priority): "haute": "bg-warning text-dark", "urgente": "bg-danger", } - return mapping.get(priority, "bg-secondary") \ No newline at end of file + return mapping.get(priority, "bg-secondary") diff --git a/app_new/pronote/templates/pronote/planning_index.html b/app_new/pronote/templates/pronote/planning_index.html index 1ada9c7..8379f9f 100644 --- a/app_new/pronote/templates/pronote/planning_index.html +++ b/app_new/pronote/templates/pronote/planning_index.html @@ -62,7 +62,7 @@
{% endif %} - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %}
📊 Statistiques
diff --git a/app_new/templates/admin/permissions.html b/app_new/templates/admin/permissions.html index bf21715..d56b455 100644 --- a/app_new/templates/admin/permissions.html +++ b/app_new/templates/admin/permissions.html @@ -18,36 +18,34 @@
- {% for role_key, role_name in roles.items() %} + {% for role in roles_list %}
- {{ role_name }} - {{ users_by_role[role_key]|length }} compte(s) + {{ role.name }} + {{ role.user_links|length }} compte(s)

Permissions

- {% if '*' in permissions_by_role[role_key] %} + {% if role.permission_links|selectattr('permission.code', 'equalto', '*')|list %} Accès complet {% else %}
- {% for permission in permissions_by_role[role_key]|sort %} - {{ permission }} + {% for link in role.permission_links if link.effect == 'allow' and link.permission.is_active %} + {{ link.permission.code }} {% endfor %}
{% endif %} - {% if users_by_role[role_key] %} + {% if role.user_links %}
- {% for user in users_by_role[role_key] %} + {% for link in role.user_links %}{% set user = link.user %}
{{ user.full_name or user.username }}
- +
diff --git a/app_new/templates/admin/user_form.html b/app_new/templates/admin/user_form.html index 189ad56..306c3ff 100644 --- a/app_new/templates/admin/user_form.html +++ b/app_new/templates/admin/user_form.html @@ -39,12 +39,16 @@
- - + +
+ {% for role in roles_list|default([]) %} +
+ {% else %}
Aucun rôle RBAC actif.
{% endfor %} +
+ Un utilisateur peut posséder plusieurs rôles. Le champ historique User.role n’est pas utilisé pour la sécurité.
{% if not user %} diff --git a/app_new/templates/admin/user_permissions.html b/app_new/templates/admin/user_permissions.html deleted file mode 100644 index 81e7d58..0000000 --- a/app_new/templates/admin/user_permissions.html +++ /dev/null @@ -1,205 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Permissions utilisateur - {{ user.full_name }} - GMAO Collège{% endblock %} - -{% block content %} -
-
-
-

- Permissions de {{ user.full_name }} -

-

- Rôle actuel : {{ user.role|title }} -

-
- -
- - -
-
-
-
-
Informations
-
-
- - - - - - - - - - - - - - - - - -
Nom d'utilisateur:{{ user.username }}
Email:{{ user.email or 'Non défini' }}
Statut: - {% if user.is_active %} - Actif - {% else %} - Inactif - {% endif %} -
Date de création:{{ user.created_at|datetime_fmt if user.created_at else 'Non défini' }}
-
-
-
-
-
-
-
Permissions actuelles
-
-
-
- {% for perm in user_permissions %} -
-
- {{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }} -
-
- {% endfor %} - {% if not user_permissions %} -
-
- Aucune permission définie -
-
- {% endif %} -
-
-
-
-
- - -
-
-
Modifier le rôle
-
-
- -
-
- - -
- Le changement de rôle mettra à jour automatiquement les permissions de l'utilisateur. -
-
-
- -
- {% for perm in all_permissions %} -
- - -
- {% endfor %} -
-
-
-
- Annuler - -
- -
-
- - -
-
-
Comparaison des permissions par rôle
-
-
-
- - - - - - - - - - - - - {% for perm in all_permissions %} - - - - - - - - - {% endfor %} - -
PermissionAdminChefTechnicienDemandeurActuel
{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }} - {% if perm in get_role_permissions('admin') %} - - {% else %} - - {% endif %} - - {% if perm in get_role_permissions('chef') %} - - {% else %} - - {% endif %} - - {% if perm in get_role_permissions('technicien') %} - - {% else %} - - {% endif %} - - {% if perm in get_role_permissions('demandeur') %} - - {% else %} - - {% endif %} - - {% if perm in user_permissions %} - - {% else %} - - {% endif %} -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/app_new/templates/admin/users.html b/app_new/templates/admin/users.html index 52db313..e6c7025 100644 --- a/app_new/templates/admin/users.html +++ b/app_new/templates/admin/users.html @@ -29,8 +29,8 @@ {{ user.full_name or user.username }} {{ user.email or '-' }} - - {{ user.role }} + + {{ user.role_label }} diff --git a/app_new/templates/admin/users_list.html b/app_new/templates/admin/users_list.html index c63e234..e0e6ea5 100644 --- a/app_new/templates/admin/users_list.html +++ b/app_new/templates/admin/users_list.html @@ -54,12 +54,10 @@ {{ user.email or 'Non défini' }} - {{ user.role|title }} + {{ user.role_label }} @@ -71,7 +69,7 @@
- @@ -125,4 +123,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/auth/profile.html b/app_new/templates/auth/profile.html index 059f207..4f32acf 100644 --- a/app_new/templates/auth/profile.html +++ b/app_new/templates/auth/profile.html @@ -31,9 +31,9 @@
Rôle
- {% if user.role == 'admin' %} + {% if user.is_admin() %} Administrateur - {% elif user.role == 'tech' %} + {% elif user.has_permission('patrimoine.manage') %} Technicien {% else %} Utilisateur @@ -62,4 +62,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/auth/user_form.html b/app_new/templates/auth/user_form.html index e03ff5a..9bd53e6 100644 --- a/app_new/templates/auth/user_form.html +++ b/app_new/templates/auth/user_form.html @@ -34,8 +34,16 @@
- {{ form.role.label(class="form-label") }} - {{ form.role(class="form-select") }} + + +
+ {% for role in roles_list|default([]) %} +
+ {% endfor %} +
+ Plusieurs rôles peuvent être sélectionnés.
@@ -63,4 +71,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/auth/users.html b/app_new/templates/auth/users.html index 6cdcdee..58002fe 100644 --- a/app_new/templates/auth/users.html +++ b/app_new/templates/auth/users.html @@ -19,11 +19,9 @@ {{ user.username }} {{ user.email }} - {% if user.role == 'admin' %}Administrateur - {% elif user.role == 'chef' %}Chef d'établissement - {% elif user.role == 'technicien' %}Technicien - {% elif user.role == 'demandeur' %}Demandeur - {% else %}{{ user.role }}{% endif %} + {% if user.is_admin() %}Administrateur + {% elif user.has_permission('patrimoine.manage') %}{{ user.role_label }} + {% else %}{{ user.role_label }}{% endif %} {% if user.is_active %}Actif @@ -51,4 +49,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/ent/code_editor.html b/app_new/templates/ent/code_editor.html index e945078..24d59da 100644 --- a/app_new/templates/ent/code_editor.html +++ b/app_new/templates/ent/code_editor.html @@ -60,11 +60,11 @@
- Utilisateur actuel : {{ current_user.username }} ({{ current_user.role }}) + Utilisateur actuel : {{ current_user.username }} ({{ current_user.role_label }})
Permission code:edit : - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} ✅ Accordée {% else %} ❌ Refusée @@ -130,4 +130,4 @@ window.onload = function() { }; {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/ent/config.html b/app_new/templates/ent/config.html index 0a9fed5..8582c78 100644 --- a/app_new/templates/ent/config.html +++ b/app_new/templates/ent/config.html @@ -79,7 +79,7 @@
- {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %}
{% endblock %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/ent/logs_viewer.html b/app_new/templates/ent/logs_viewer.html index cedaee4..556c27b 100644 --- a/app_new/templates/ent/logs_viewer.html +++ b/app_new/templates/ent/logs_viewer.html @@ -52,11 +52,11 @@
- Utilisateur actuel : {{ current_user.username }} ({{ current_user.role }}) + Utilisateur actuel : {{ current_user.username }} ({{ current_user.role_label }})
Permission code:edit : - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} ✅ Accordée {% else %} ❌ Refusée @@ -67,4 +67,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/pronote/planning_index.html b/app_new/templates/pronote/planning_index.html index 015c7ee..52cb495 100644 --- a/app_new/templates/pronote/planning_index.html +++ b/app_new/templates/pronote/planning_index.html @@ -60,7 +60,7 @@
{% endif %} - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %}
📊 Statistiques
diff --git a/app_new/templates/training/detail.html b/app_new/templates/training/detail.html index 3d52010..922a8d9 100644 --- a/app_new/templates/training/detail.html +++ b/app_new/templates/training/detail.html @@ -7,7 +7,7 @@

{{ training.name }}

- {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} Modifier {% endif %} Retour diff --git a/app_new/templates/training/index.html b/app_new/templates/training/index.html index 9b556c6..6149766 100644 --- a/app_new/templates/training/index.html +++ b/app_new/templates/training/index.html @@ -6,7 +6,7 @@

Formations

- {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} Créer une formation {% endif %}
diff --git a/app_new/training/templates/detail.html b/app_new/training/templates/detail.html index f51afad..e6cbfbc 100644 --- a/app_new/training/templates/detail.html +++ b/app_new/training/templates/detail.html @@ -7,7 +7,7 @@

{{ training.name }}

- {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} Modifier {% endif %} Retour @@ -53,4 +53,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/training/templates/index.html b/app_new/training/templates/index.html index 209661e..dfde9b8 100644 --- a/app_new/training/templates/index.html +++ b/app_new/training/templates/index.html @@ -6,7 +6,7 @@

Formations

- {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %} Créer une formation {% endif %}
@@ -34,4 +34,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/migrations/versions/9e39f0a1b2c3_garantir_roles_admin_rbac.py b/migrations/versions/9e39f0a1b2c3_garantir_roles_admin_rbac.py new file mode 100644 index 0000000..7ecefe5 --- /dev/null +++ b/migrations/versions/9e39f0a1b2c3_garantir_roles_admin_rbac.py @@ -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 + diff --git a/tests/conftest.py b/tests/conftest.py index 2f6b83d..a65adcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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} diff --git a/tests/factories/user.py b/tests/factories/user.py index 14df2ec..7d51340 100644 --- a/tests/factories/user.py +++ b/tests/factories/user.py @@ -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' diff --git a/tests/integration/test_authorization.py b/tests/integration/test_authorization.py index e6133d8..93c8e9b 100644 --- a/tests/integration/test_authorization.py +++ b/tests/integration/test_authorization.py @@ -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}) diff --git a/tests/integration/test_rbac.py b/tests/integration/test_rbac.py index bf0b902..9cef4ff 100644 --- a/tests/integration/test_rbac.py +++ b/tests/integration/test_rbac.py @@ -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 - diff --git a/tests/integration/test_request_security.py b/tests/integration/test_request_security.py index e83ec5b..232da0c 100644 --- a/tests/integration/test_request_security.py +++ b/tests/integration/test_request_security.py @@ -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 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 13b8d5c..f6cbdf9 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -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() diff --git a/tests/unit/test_production_config.py b/tests/unit/test_production_config.py new file mode 100644 index 0000000..3f0ebbb --- /dev/null +++ b/tests/unit/test_production_config.py @@ -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 + diff --git a/wsgi.py b/wsgi.py index f81b45a..41002d7 100644 --- a/wsgi.py +++ b/wsgi.py @@ -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) \ No newline at end of file + app.run(host='0.0.0.0', port=5080)