From 20362fb31ec067825fe952ea2691a9a644585008 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 18:10:29 +0000 Subject: [PATCH] =?UTF-8?q?Renforcer=20le=20RBAC=20et=20s=C3=A9parer=20les?= =?UTF-8?q?=20administrateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/cleaning/routes.py | 7 ++ app_new/core/authorization.py | 110 ++++++++++++++-- app_new/core/routes/admin.py | 62 +++++++-- app_new/core/services/rbac.py | 44 ++++++- app_new/equipments/main.py | 4 + app_new/interventions/crud.py | 11 ++ app_new/templates/admin/permissions.html | 13 +- app_new/templates/admin/role_permissions.html | 10 +- app_new/templates/admin/user_access.html | 50 +++++++- app_new/templates/auth/users.html | 2 +- ...4a1b2c3d5e_separer_super_admin_et_admin.py | 119 ++++++++++++++++++ tests/integration/test_rbac.py | 50 +++++++- 12 files changed, 445 insertions(+), 37 deletions(-) create mode 100644 migrations/versions/9f4a1b2c3d5e_separer_super_admin_et_admin.py diff --git a/app_new/cleaning/routes.py b/app_new/cleaning/routes.py index 5bb8c24..a1de643 100644 --- a/app_new/cleaning/routes.py +++ b/app_new/cleaning/routes.py @@ -16,6 +16,7 @@ from ..core.models import ( ) from .services.stock import StockError, receive_stock, issue_stock, transfer_stock, transvasement, dilute from .services.forecast import forecast_product +from ..core.authorization import permission_required cleaning_bp = Blueprint("cleaning", __name__, url_prefix="/cleaning", template_folder="templates") @@ -153,6 +154,7 @@ def locations(): @cleaning_bp.route("/receive", methods=["GET", "POST"]) @login_required +@permission_required("stock.receive") def receive(): products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all() if request.method == "POST": @@ -167,6 +169,7 @@ def receive(): @cleaning_bp.route("/issue", methods=["GET", "POST"]) @login_required +@permission_required("stock.issue") def issue(): if request.method == "POST": try: @@ -179,6 +182,7 @@ def issue(): @cleaning_bp.route("/transfer", methods=["GET", "POST"]) @login_required +@permission_required("stock.transfer") def transfer(): if request.method == "POST": try: @@ -196,6 +200,7 @@ def movements(): @cleaning_bp.route("/transvasement", methods=["GET", "POST"]) @login_required +@permission_required("stock.transvasement") def transvasement_page(): if request.method == "POST": try: @@ -217,6 +222,7 @@ def forecast(product_id): @cleaning_bp.route("/forecast-config", methods=["GET", "POST"]) @login_required +@permission_required("stock.configure") def forecast_config(): config = CleaningForecastConfig.query.filter_by(is_active=True).first() if request.method == "POST": @@ -234,6 +240,7 @@ def forecast_config(): @cleaning_bp.route("/inventory", methods=["GET", "POST"]) @login_required +@permission_required("stock.inventory") def inventory(): if request.method == "POST": location = StockLocation.query.get_or_404(int(request.form["location_id"])) diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py index 07c2d85..6bcc299 100644 --- a/app_new/core/authorization.py +++ b/app_new/core/authorization.py @@ -11,6 +11,8 @@ from flask_login import current_user ROLE_ALIASES = { + # Alias historique conservé uniquement pour l'affichage/compatibilité. + # La sécurité utilise le slug RBAC réel ``super_admin``. "super_admin": "admin", "admin": "admin", "chef": "responsable_gmao", @@ -28,7 +30,8 @@ ROLE_ALIASES = { } ROLE_LABELS = { - "admin": "Administrateur système", + "super_admin": "Super administrateur", + "admin": "Administrateur", "responsable_gmao": "Responsable GMAO", "technicien": "Technicien", "assistant_prevention": "Assistant de prévention", @@ -39,7 +42,27 @@ ROLE_LABELS = { # 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": {"*"}, + # LEGACY_SEED_ONLY : le rôle admin est désormais un rôle explicite. Le + # wildcard appartient exclusivement à super_admin (voir migration). + "super_admin": {"*"}, + "admin": { + "dashboard.view", "intervention.view", "intervention.create", "intervention.edit", + "intervention.assign", "intervention.change_status", "intervention.close", "intervention.reject", + "intervention.delete", "intervention.postpone", "intervention.comment", + "patrimoine.view", "patrimoine.create", "patrimoine.edit", "patrimoine.move", + "patrimoine.change_quantity", "patrimoine.retire", "patrimoine.delete", + "planning.view", "planning.create", "planning.edit", "planning.delete", "planning.manage", + "stock.view", "stock.edit", "stock.receive", "stock.issue", "stock.transfer", "stock.inventory", + "stock.adjust", "stock.destroy", "stock.transvasement", "stock.dilute", "stock.configure", + "contract.view", "contract.manage", "prevention.view", "prevention.manage", "export.use", + "housing.private", "gmao_config.view", "gmao_config.configure", "watchdog_dnd.view", + "watchdog_dnd.configure", "integration.ent.view", "integration.ent.configure", + "integration.outlook.view", "integration.outlook.configure", "integration.pronote.view", + "integration.pronote.configure", "integration.yeastar.view", "integration.yeastar.configure", + "user.view", "user.create", "user.edit", "user.delete", "user.manage", "role.view", + "role.create", "role.edit", "role.archive", "role.manage", "audit.view", "audit.export", + "system.view", "system.configure", "system.admin", + }, "responsable_gmao": { "dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", @@ -79,14 +102,10 @@ STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"} CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"} PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"} -# Catalogue affiché dans la matrice RBAC. Les anciennes permissions -# ``*.manage`` restent compatibles et impliquent les actions ci-dessous. +# Catalogue réellement utilisé par les routes et les fonctions métier. GRANULAR_PERMISSION_CODES = { - f"{module}.{action}" - for module in ("dashboard", "patrimoine", "intervention", "planning", "stock", "contract", "prevention", "user", "system") - for action in ("view", "create", "edit", "delete", "validate", "reject", "export", "configure", "manage") -} -GRANULAR_PERMISSION_CODES |= { + code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*" +} | { "gmao_config.view", "gmao_config.configure", "watchdog_dnd.view", "watchdog_dnd.configure", "integration.ent.view", "integration.ent.configure", @@ -111,6 +130,42 @@ PERMISSION_LABELS = { "system": "Système", } +ACTION_LABELS = { + "view": ("Consulter", "Permet de consulter les informations du domaine."), + "create": ("Créer", "Permet d'enregistrer de nouvelles informations dans le domaine."), + "edit": ("Modifier", "Permet de modifier les informations existantes du domaine."), + "delete": ("Supprimer", "Permet de supprimer ou retirer les éléments concernés."), + "manage": ("Administrer", "Permet d'administrer les éléments et paramètres du domaine."), + "configure": ("Configurer", "Permet de modifier la configuration du domaine."), + "export": ("Exporter", "Permet d'exporter les informations du domaine."), + "validate": ("Valider", "Permet de valider l'opération concernée."), + "reject": ("Refuser", "Permet de refuser l'opération concernée."), + "assign": ("Affecter", "Permet d'affecter l'opération à une personne ou une entreprise."), + "change_status": ("Changer le statut", "Permet de modifier le statut de l'opération."), + "close": ("Clôturer", "Permet de clôturer l'opération et d'enregistrer son état final."), + "move": ("Déplacer", "Permet de déplacer les éléments concernés entre les emplacements."), + "receive": ("Réceptionner", "Permet d'enregistrer les réceptions et l'entrée en stock."), + "issue": ("Sortir du stock", "Permet d'enregistrer une sortie de stock, notamment vers un agent."), + "transfer": ("Transférer", "Permet de transférer une quantité entre emplacements."), + "inventory": ("Réaliser un inventaire", "Permet d'enregistrer un comptage physique et ses écarts."), + "adjust": ("Corriger un stock", "Permet d'enregistrer une correction de stock tracée."), + "destroy": ("Détruire", "Permet d'enregistrer la destruction de produits."), + "transvasement": ("Transvaser", "Permet d'enregistrer un transvasement de produit."), + "dilute": ("Préparer une dilution", "Permet d'enregistrer la préparation d'un produit dilué."), + "archive": ("Archiver", "Permet d'archiver le domaine concerné sans effacer son historique."), + "private": ("Consulter les données privées", "Permet de consulter les données personnelles protégées."), + "all": ("Accès complet", "Accès complet à l'application, réservé au super administrateur."), +} + +def permission_metadata(code): + """Retourne un libellé et une description compréhensibles pour l'UI.""" + if code == "*": + return "Accès complet à l'application", ACTION_LABELS["all"][1] + module, _, action = code.partition(".") + domain = PERMISSION_LABELS.get(module, module.replace("_", " ").capitalize()) + label, description = ACTION_LABELS.get(action, (action.replace("_", " ").capitalize(), f"Permet d'effectuer l'action « {action.replace('_', ' ')} » dans le domaine {domain}.")) + return f"{label} — {domain}", description.replace("le domaine", f"le domaine « {domain} »") + def canonical_role(role): return ROLE_ALIASES.get((role or "").strip().lower(), "lecture") @@ -130,6 +185,8 @@ def has_permission(permission, user=None): 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] + if any(role.slug == "super_admin" for role in roles): + return True if roles: granted = { link.permission.code @@ -187,6 +244,22 @@ def required_permission(endpoint, method): mutating = method not in {"GET", "HEAD", "OPTIONS"} if blueprint == "cleaning": + explicit = { + "cleaning.receive": "stock.receive", + "cleaning.issue": "stock.issue", + "cleaning.transfer": "stock.transfer", + "cleaning.transvasement_page": "stock.transvasement", + "cleaning.inventory": "stock.inventory", + "cleaning.forecast_config": "stock.configure", + "cleaning.locations": "stock.configure", + "cleaning.material_assign": "patrimoine.edit", + "cleaning.product_new": "stock.configure", + "cleaning.product_edit": "stock.configure", + "cleaning.reference_new": "stock.configure", + "cleaning.packaging_new": "stock.configure", + } + if endpoint in explicit: + return explicit[endpoint] action = "view" if mutating: action = "create" if any(token in endpoint for token in ("new", "receive", "issue", "transfer", "transvasement", "inventory")) else "edit" @@ -223,13 +296,24 @@ def required_permission(endpoint, method): if blueprint == "setup_wizard": return "system.admin" if blueprint in PATRIMOINE_BLUEPRINTS: - return "patrimoine.manage" if mutating else "patrimoine.view" + return "patrimoine.edit" if mutating else "patrimoine.view" if blueprint in PLANNING_BLUEPRINTS: - return "planning.manage" if mutating else "planning.view" + return "planning.edit" if mutating else "planning.view" if blueprint == "interventions": + explicit = { + "interventions.change_status": "intervention.change_status", + "interventions.refuse": "intervention.reject", + "interventions.soft_delete": "intervention.delete", + "interventions.postpone": "intervention.postpone", + "interventions.add_comment": "intervention.comment", + "interventions.save_work_details": "intervention.edit", + "interventions.update_location": "intervention.edit", + } + if endpoint in explicit: + return explicit[endpoint] if endpoint in {"interventions.create", "interventions.create_for_group"}: return "intervention.create" - return "intervention.manage" if mutating else "intervention.view" + return "intervention.edit" if mutating else "intervention.view" if blueprint == "documents": if "intervention" in endpoint: return "intervention.manage" if mutating else "intervention.view" @@ -237,7 +321,7 @@ def required_permission(endpoint, method): return "patrimoine.manage" if mutating else "patrimoine.view" return "system.admin" if blueprint in STOCK_BLUEPRINTS: - return "stock.manage" if mutating else "stock.view" + return "stock.edit" if mutating else "stock.view" if blueprint in CONTRACT_BLUEPRINTS: return "contract.manage" if mutating else "contract.view" if blueprint in PREVENTION_BLUEPRINTS: diff --git a/app_new/core/routes/admin.py b/app_new/core/routes/admin.py index 819215b..5b8b852 100644 --- a/app_new/core/routes/admin.py +++ b/app_new/core/routes/admin.py @@ -14,8 +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 ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS -from ..services.rbac import replace_user_roles +from ..authorization import ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS, permission_metadata +from ..services.rbac import replace_user_roles, ensure_super_admin_survives, LastSuperAdminError admin_bp = Blueprint('admin', __name__) @@ -116,7 +116,13 @@ def user_edit(user_id): 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.is_active = request.form.get('is_active') == 'on' + new_active = request.form.get('is_active') == 'on' + try: + ensure_super_admin_survives(user, will_be_active=new_active) + except LastSuperAdminError as exc: + flash(str(exc), 'danger') + return redirect(url_for('admin.user_edit', user_id=user.id)) + user.is_active = new_active new_password = request.form.get('new_password') if new_password: @@ -125,8 +131,13 @@ def user_edit(user_id): return render_template('admin/user_form.html', user=user), 400 user.set_password(new_password) - replace_user_roles(user, selected_role_ids, assigned_by=current_user) - db.session.commit() + try: + replace_user_roles(user, selected_role_ids, assigned_by=current_user) + db.session.commit() + except LastSuperAdminError as exc: + db.session.rollback() + flash(str(exc), 'danger') + return redirect(url_for('admin.user_edit', user_id=user.id)) flash('Utilisateur modifié avec succès.', 'success') return redirect(url_for('admin.users')) @@ -141,7 +152,7 @@ def user_edit(user_id): def permissions(): """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) + return render_template('admin/permissions.html', roles_list=roles_list, permission_metadata=permission_metadata) @admin_bp.route('/roles', methods=['GET', 'POST']) @@ -169,6 +180,9 @@ def roles(): @admin_required def role_permissions(role_id): role = Role.query.get_or_404(role_id) + if role.slug == 'super_admin': + flash('Les permissions du super administrateur sont fixes : il possède toujours un accès complet.', 'info') + return redirect(url_for('admin.roles')) permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all() if request.method == 'POST': selected = {int(value) for value in request.form.getlist('permission_ids')} @@ -180,7 +194,7 @@ def role_permissions(role_id): flash(f'Permissions du rôle « {role.name} » enregistrées.', 'success') return redirect(url_for('admin.roles')) selected = {link.permission_id for link in role.permission_links if link.effect == 'allow'} - return render_template('admin/role_permissions.html', role=role, permissions_list=permissions_list, selected=selected, permission_labels=PERMISSION_LABELS) + return render_template('admin/role_permissions.html', role=role, permissions_list=permissions_list, selected=selected, permission_labels=PERMISSION_LABELS, permission_metadata=permission_metadata) @admin_bp.route('/roles//archive', methods=['POST']) @@ -206,7 +220,12 @@ 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')} - replace_user_roles(user, selected_roles, assigned_by=current_user) + try: + replace_user_roles(user, selected_roles, assigned_by=current_user) + except LastSuperAdminError as exc: + db.session.rollback() + flash(str(exc), 'danger') + return redirect(url_for('admin.user_access', user_id=user.id)) # Les exceptions explicites sont optionnelles et tracées ; une case # « refuser » est prioritaire sur les rôles. UserPermission.query.filter_by(user_id=user.id).delete(synchronize_session=False) @@ -219,7 +238,22 @@ def user_access(user_id): return redirect(url_for('admin.users')) selected_roles = {link.role_id for link in user.role_links} overrides = {link.permission_id: link.effect for link in user.permission_links} - return render_template('admin/user_access.html', user=user, roles_list=roles_list, permissions_list=permissions_list, selected_roles=selected_roles, overrides=overrides) + effective = {permission.code: has_permission(permission.code, user) for permission in permissions_list} + provenance = {} + for permission in permissions_list: + if overrides.get(permission.id) == 'deny': + provenance[permission.code] = 'Refus individuel' + elif overrides.get(permission.id) == 'allow': + provenance[permission.code] = 'Autorisation individuelle' + else: + provenance[permission.code] = ', '.join( + link.role.name for link in user.role_links + if link.role and link.role.is_active and any( + rp.permission_id == permission.id and rp.effect == 'allow' + for rp in link.role.permission_links + ) + ) or 'Aucun rôle' + return render_template('admin/user_access.html', user=user, roles_list=roles_list, permissions_list=permissions_list, selected_roles=selected_roles, overrides=overrides, effective=effective, provenance=provenance, permission_metadata=permission_metadata) @admin_bp.route('/audit') @@ -267,8 +301,14 @@ def user_delete(user_id): if user.id == current_user.id: flash('Vous ne pouvez pas supprimer votre propre compte.', 'danger') else: - db.session.delete(user) - db.session.commit() + try: + ensure_super_admin_survives(user, deleting=True) + db.session.delete(user) + db.session.commit() + except LastSuperAdminError as exc: + db.session.rollback() + flash(str(exc), 'danger') + return redirect(url_for('admin.users')) flash('Utilisateur supprimé.', 'success') return redirect(url_for('admin.users')) diff --git a/app_new/core/services/rbac.py b/app_new/core/services/rbac.py index 8d26b47..53f5e8d 100644 --- a/app_new/core/services/rbac.py +++ b/app_new/core/services/rbac.py @@ -6,6 +6,37 @@ Cela permet aux routes d'enregistrer simultanément l'utilisateur et ses accès. from ...extensions import db from ..models.rbac import Role, UserRole +from ..models.user import User + + +class LastSuperAdminError(ValueError): + """Opération qui supprimerait le dernier super administrateur actif.""" + + +def ensure_super_admin_survives(user, *, will_be_active=None, deleting=False): + """Bloque la désactivation/suppression du dernier super-admin actif.""" + if not any(link.role and link.role.slug == "super_admin" and link.role.is_active for link in user.role_links): + return + if not deleting and (will_be_active is None or will_be_active): + return + count = UserRole.query.join(Role).join(User, User.id == UserRole.user_id).filter( + Role.slug == "super_admin", Role.is_active.is_(True), + db.or_(User.is_active.is_(True), User.is_active.is_(None)) + ).count() + if count <= 1: + raise LastSuperAdminError("Impossible de désactiver ou supprimer le dernier super administrateur actif.") + + +def _is_last_super_admin(user, selected_ids): + super_role = Role.query.filter_by(slug="super_admin").first() + if not super_role or super_role.id not in {int(value) for value in selected_ids}: + if super_role and any(link.role_id == super_role.id for link in user.role_links): + count = UserRole.query.join(Role).join(User, User.id == UserRole.user_id).filter( + Role.slug == "super_admin", Role.is_active.is_(True), + db.or_(User.is_active.is_(True), User.is_active.is_(None)) + ).count() + return count <= 1 + return False def replace_user_roles(user, role_ids, assigned_by=None): @@ -29,6 +60,18 @@ def replace_user_roles(user, role_ids, assigned_by=None): } if normalized_ids else {} selected_ids = set(active_roles) + super_role = Role.query.filter_by(slug="super_admin").first() + if super_role and super_role.id in selected_ids and assigned_by is not None: + actor_is_super = any( + link.role and link.role.slug == "super_admin" and link.role.is_active + for link in getattr(assigned_by, "role_links", []) + ) + if not actor_is_super: + raise LastSuperAdminError("Seul un super administrateur peut attribuer le rôle super_admin.") + + if _is_last_super_admin(user, selected_ids): + raise LastSuperAdminError("Impossible de retirer le dernier super administrateur actif.") + 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: @@ -42,4 +85,3 @@ def replace_user_roles(user, role_ids, assigned_by=None): )) return [active_roles[role_id] for role_id in sorted(selected_ids)] - diff --git a/app_new/equipments/main.py b/app_new/equipments/main.py index 439245f..a083cdd 100644 --- a/app_new/equipments/main.py +++ b/app_new/equipments/main.py @@ -15,6 +15,7 @@ from ..core.services.equipment_lifecycle import ( confirm_disposal, propose_disposal, record_event, start_repair as start_equipment_repair, update_future_task_location, ) +from ..core.authorization import permission_required def _empty_to_none(v): """Convertit une chaîne vide en None.""" @@ -778,6 +779,7 @@ def init_scheduled_tasks(id): @main_bp.route('//move', methods=['GET', 'POST']) @login_required +@permission_required('patrimoine.move') def move_to_room(id): """Déplace une unité ou une quantité, sans casser les totaux des groupes.""" equipment = Equipment.query.get_or_404(id) @@ -876,6 +878,7 @@ def move_to_room(id): @main_bp.route('//repair/start', methods=['POST']) @login_required +@permission_required('patrimoine.retire') def start_repair(id): equipment = Equipment.query.get_or_404(id) quantity = request.form.get('quantity', 1, type=int) or 1 @@ -895,6 +898,7 @@ def start_repair(id): @main_bp.route('//repair/complete', methods=['POST']) @login_required +@permission_required('patrimoine.edit') def complete_repair(id): equipment = Equipment.query.get_or_404(id) complete_equipment_repair(equipment, request.form.get('reason'), current_user.id) diff --git a/app_new/interventions/crud.py b/app_new/interventions/crud.py index df326b9..6773248 100644 --- a/app_new/interventions/crud.py +++ b/app_new/interventions/crud.py @@ -23,6 +23,7 @@ from ..core.models.equipment import Equipment from ..core.models.college import Building, Zone, Room from ..core.models.company import Service, Company from app_new.constants import INTERVENTION_STATUSES, INTERVENTION_TRANSITIONS, PRIORITIES, WORKFLOW_TYPES, WORKFLOW_STATUS_ORDERS +from ..core.authorization import has_permission, permission_required interventions_bp = Blueprint('interventions', __name__, template_folder='templates') @@ -120,6 +121,7 @@ def index(): @interventions_bp.route('/new', methods=['GET', 'POST']) @login_required +@permission_required('intervention.create') def create(): """Créer une intervention.""" from ..core.models.user import User @@ -490,6 +492,7 @@ def update_location(id): @interventions_bp.route('//edit', methods=['GET', 'POST']) @login_required +@permission_required('intervention.edit') def edit(id): """Modifier une intervention.""" from ..core.models.user import User @@ -566,6 +569,7 @@ def edit(id): @interventions_bp.route('//status', methods=['POST']) @login_required +@permission_required('intervention.change_status') def change_status(id): """Changer le statut d'une intervention.""" intervention = Intervention.query.get_or_404(id) @@ -580,6 +584,9 @@ def change_status(id): if new_status not in workflow_statuses: flash(f'Le statut « {new_status} » ne correspond pas au workflow {intervention.workflow_label}.', 'danger') return redirect(url_for('interventions.detail', id=intervention.id)) + if new_status in {'terminee', 'cloturee'} and not has_permission('intervention.close', current_user): + flash('Vous ne disposez pas du droit de clôturer cette intervention.', 'danger') + return redirect(url_for('interventions.detail', id=intervention.id)) if new_status != old_status and new_status not in allowed: flash(f'Transition interdite : {old_status} → {new_status}.', 'danger') return redirect(url_for('interventions.detail', id=intervention.id)) @@ -618,6 +625,7 @@ def change_status(id): @interventions_bp.route('//refuse', methods=['POST']) @login_required +@permission_required('intervention.reject') def refuse(id): """Refuse une demande avec un motif obligatoire.""" intervention = Intervention.query.get_or_404(id) @@ -641,6 +649,7 @@ def refuse(id): @interventions_bp.route('//comment', methods=['POST']) @login_required +@permission_required('intervention.comment') def add_comment(id): """Ajouter un commentaire à une intervention.""" intervention = Intervention.query.get_or_404(id) @@ -661,6 +670,7 @@ def add_comment(id): @interventions_bp.route('//soft-delete', methods=['POST']) @login_required +@permission_required('intervention.delete') def soft_delete(id): """Mettre une intervention en corbeille (soft delete).""" intervention = Intervention.query.get_or_404(id) @@ -674,6 +684,7 @@ def soft_delete(id): @interventions_bp.route('//postpone', methods=['POST']) @login_required +@permission_required('intervention.postpone') def postpone(id): """Reporter une intervention : motif, compteur, conservation date originale.""" intervention = Intervention.query.get_or_404(id) diff --git a/app_new/templates/admin/permissions.html b/app_new/templates/admin/permissions.html index d56b455..8f6ff00 100644 --- a/app_new/templates/admin/permissions.html +++ b/app_new/templates/admin/permissions.html @@ -7,14 +7,13 @@

Rôles et permissions

-

La matrice est centralisée dans l'application. Attribuez ici un rôle à chaque compte.

+

Gérez les rôles et les permissions métier de chaque domaine.

Utilisateurs
- Les permissions ne sont pas modifiables case par case : cela évite les droits invisibles et les écarts entre écrans. - Toute évolution de la matrice est versionnée et testée. + Les rôles peuvent cumuler plusieurs permissions. Le code technique reste affiché à titre secondaire pour le diagnostic.
@@ -26,16 +25,18 @@ {{ role.user_links|length }} compte(s)
-

Permissions

+ {% if role.slug == 'super_admin' %} +
Super administrateur — accès complet
Ce rôle possède automatiquement toutes les permissions de l'application. Sa matrice n'est pas configurable.
+ {% else %}

Permissions

{% if role.permission_links|selectattr('permission.code', 'equalto', '*')|list %} Accès complet {% else %}
{% for link in role.permission_links if link.effect == 'allow' and link.permission.is_active %} - {{ link.permission.code }} + {{ link.permission.name }} ({{ link.permission.code }}) {% endfor %}
- {% endif %} + {% endif %}{% endif %} {% if role.user_links %}
diff --git a/app_new/templates/admin/role_permissions.html b/app_new/templates/admin/role_permissions.html index ba8b131..dd711d8 100644 --- a/app_new/templates/admin/role_permissions.html +++ b/app_new/templates/admin/role_permissions.html @@ -3,7 +3,9 @@ {% block content %}

Permissions du rôle « {{ role.name }} »

-

Les sections sont repliées par défaut. Utilisez « Tout sélectionner » pour appliquer toutes les permissions d’un module.

+ {% if role.slug == 'super_admin' %} +
Super administrateur — accès complet
Ce rôle possède automatiquement toutes les permissions de l'application et ne peut pas être modifié.
+ {% else %}

Les sections sont repliées par défaut. Les intitulés sont fonctionnels ; le code technique est affiché en second niveau.

{% for module, permissions in permissions_list|groupby('module') %} @@ -20,8 +22,9 @@
{% endfor %} @@ -33,6 +36,7 @@ Annuler
+ {% endif %}