Renforcer le RBAC et séparer les administrateurs
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-21 18:10:29 +00:00
parent ee8c0ad5a0
commit 20362fb31e
12 changed files with 445 additions and 37 deletions

View file

@ -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"]))

View file

@ -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:

View file

@ -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/<int:role_id>/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'))

View file

@ -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)]

View file

@ -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('/<int:id>/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('/<int:id>/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('/<int:id>/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)

View file

@ -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('/<int:id>/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('/<int:id>/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('/<int:id>/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('/<int:id>/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('/<int:id>/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('/<int:id>/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)

View file

@ -7,14 +7,13 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3"><i class="bi bi-shield-check"></i> Rôles et permissions</h1>
<p class="text-muted mb-0">La matrice est centralisée dans l'application. Attribuez ici un rôle à chaque compte.</p>
<p class="text-muted mb-0">Gérez les rôles et les permissions métier de chaque domaine.</p>
</div>
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary"><i class="bi bi-people"></i> Utilisateurs</a>
</div>
<div class="alert alert-info">
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.
</div>
<div class="row g-3">
@ -26,16 +25,18 @@
<span class="badge bg-secondary">{{ role.user_links|length }} compte(s)</span>
</div>
<div class="card-body">
<h2 class="h6">Permissions</h2>
{% if role.slug == 'super_admin' %}
<div class="alert alert-danger mb-0"><strong>Super administrateur — accès complet</strong><br><small>Ce rôle possède automatiquement toutes les permissions de l'application. Sa matrice n'est pas configurable.</small></div>
{% else %}<h2 class="h6">Permissions</h2>
{% 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 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>
<span class="badge text-bg-light border" title="{{ link.permission.description }}">{{ link.permission.name }} <small class="text-muted">({{ link.permission.code }})</small></span>
{% endfor %}
</div>
{% endif %}
{% endif %}{% endif %}
{% if role.user_links %}
<hr>

View file

@ -3,7 +3,9 @@
{% block content %}
<div class="container-fluid">
<h1>Permissions du rôle « {{ role.name }} »</h1>
<p class="text-muted">Les sections sont repliées par défaut. Utilisez « Tout sélectionner » pour appliquer toutes les permissions dun module.</p>
{% if role.slug == 'super_admin' %}
<div class="alert alert-danger"><strong>Super administrateur — accès complet</strong><br>Ce rôle possède automatiquement toutes les permissions de l'application et ne peut pas être modifié.</div>
{% else %}<p class="text-muted">Les sections sont repliées par défaut. Les intitulés sont fonctionnels ; le code technique est affiché en second niveau.</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for module, permissions in permissions_list|groupby('module') %}
@ -20,8 +22,9 @@
<div class="col-xl-3 col-md-4">
<label class="border rounded p-2 d-block h-100">
<input type="checkbox" class="permission-check section-{{ section_id }}" name="permission_ids" value="{{ permission.id }}" {% if permission.id in selected %}checked{% endif %}>
<strong>{{ permission.action }}</strong><br>
<small class="text-muted">{{ permission.name }}</small>
<strong>{{ permission.name }}</strong><br>
<small class="text-muted">{{ permission.description }}</small><br>
<small class="text-secondary">Code : {{ permission.code }}</small>
</label>
</div>
{% endfor %}
@ -33,6 +36,7 @@
<button class="btn btn-primary">Enregistrer les permissions</button>
<a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.roles') }}">Annuler</a>
</form>
{% endif %}
</div>
<style>
.permission-section[open] .section-chevron { transform: rotate(90deg); }

View file

@ -1 +1,49 @@
{% extends "base.html" %}{% block title %}Accès — {{ user.username }}{% endblock %}{% block content %}<div class="container-fluid"><h1>Accès de {{ user.full_name or user.username }}</h1><form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card mb-3"><div class="card-header"><strong>Rôles</strong></div><div class="card-body row g-2">{% for role in roles_list %}<div class="col-md-3"><label class="border rounded p-2 d-block"><input type="checkbox" name="role_ids" value="{{ role.id }}" {% if role.id in selected_roles %}checked{% endif %}> {{ role.name }}<br><small><code>{{ role.slug }}</code></small></label></div>{% else %}<p>Aucun rôle RBAC disponible.</p>{% endfor %}</div></div><div class="card mb-3"><div class="card-header"><strong>Exceptions personnelles</strong><small class="d-block text-muted">Accorder ou refuser ici un droit en dehors des rôles. Un refus est prioritaire.</small></div><div class="card-body row g-2">{% for permission in permissions_list %}<div class="col-xl-3 col-md-4"><label class="small">{{ permission.code }}</label><select class="form-select form-select-sm" name="permission_{{ permission.id }}"><option value="">Hérité du rôle</option><option value="allow" {% if overrides.get(permission.id)=='allow' %}selected{% endif %}>Autoriser</option><option value="deny" {% if overrides.get(permission.id)=='deny' %}selected{% endif %}>Refuser</option></select></div>{% else %}<p>Aucune permission RBAC disponible.</p>{% endfor %}</div></div><button class="btn btn-primary">Enregistrer les accès</button><a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.users') }}">Annuler</a></form></div>{% endblock %}
{% extends "base.html" %}
{% block title %}Accès — {{ user.username }}{% endblock %}
{% block content %}
<div class="container-fluid">
<h1>Accès de {{ user.full_name or user.username }}</h1>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card mb-3">
<div class="card-header"><strong>Rôles</strong></div>
<div class="card-body row g-2">
<p class="text-muted">Les permissions effectives sont lunion des rôles actifs. Un utilisateur peut cumuler plusieurs rôles.</p>
{% for role in roles_list %}
<div class="col-md-3"><label class="border rounded p-2 d-block h-100">
<input type="checkbox" name="role_ids" value="{{ role.id }}" {% if role.id in selected_roles %}checked{% endif %}>
<strong>{{ role.name }}</strong><br><small class="text-muted">{{ role.description or 'Rôle disponible dans la GMAO.' }}</small>
</label></div>
{% else %}<p>Aucun rôle RBAC disponible.</p>{% endfor %}
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong>Exceptions individuelles</strong><small class="d-block text-muted">Hériter utilise les permissions des rôles. Autoriser ajoute un accès ; Refuser reste prioritaire sur les rôles.</small></div>
<div class="card-body">
{% for module, permissions in permissions_list|groupby('module') %}
<details class="mb-3" {% if loop.first %}open{% endif %}>
<summary class="fw-bold">{{ module|replace('_', ' ')|title }}</summary>
<div class="row g-2 mt-1">
{% for permission in permissions %}
<div class="col-xl-4 col-md-6">
<label class="small fw-semibold" for="permission-{{ permission.id }}">{{ permission.name }}</label>
<select id="permission-{{ permission.id }}" class="form-select form-select-sm" name="permission_{{ permission.id }}">
<option value="">Hériter</option>
<option value="allow" {% if overrides.get(permission.id)=='allow' %}selected{% endif %}>Autoriser</option>
<option value="deny" {% if overrides.get(permission.id)=='deny' %}selected{% endif %}>Refuser</option>
</select>
<small class="text-muted d-block">{{ permission.description }}</small>
<small class="text-secondary">Accès actuel : <strong>{{ 'AUTORISÉ' if effective.get(permission.code) else 'REFUSÉ' }}</strong>{% if provenance.get(permission.code) %} · {{ provenance.get(permission.code) }}{% endif %}</small>
<small class="text-secondary d-block">Code : {{ permission.code }}</small>
</div>
{% endfor %}
</div>
</details>
{% else %}<p>Aucune permission RBAC disponible.</p>{% endfor %}
</div>
</div>
<button class="btn btn-primary">Enregistrer les accès</button>
<a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.users') }}">Annuler</a>
</form>
</div>
{% endblock %}

View file

@ -20,7 +20,7 @@
<td>{{ user.email }}</td>
<td>
{% 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>
{% elif user.has_permission('patrimoine.edit') %}<span class="badge bg-primary">{{ user.role_label }}</span>
{% else %}<span class="badge bg-secondary">{{ user.role_label }}</span>{% endif %}
</td>
<td>

View file

@ -0,0 +1,119 @@
"""Sépare le super administrateur du rôle administrateur configurable.
La migration ne supprime aucune donnée. Les comptes existants reçoivent
explicitement ``super_admin`` afin de garantir la continuité d'accès.
"""
from alembic import op
import sqlalchemy as sa
revision = "9f4a1b2c3d5e"
down_revision = "9e39f0a1b2c3"
branch_labels = None
depends_on = None
PERMISSIONS = {
"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",
}
LABELS = {
"dashboard.view": ("Consulter le tableau de bord", "Permet d'accéder au tableau de bord général de la GMAO."),
"intervention.view": ("Consulter les interventions", "Permet de consulter la liste et le détail des interventions."),
"intervention.create": ("Créer une intervention", "Permet d'enregistrer une nouvelle intervention dans la GMAO."),
"intervention.edit": ("Modifier une intervention", "Permet de modifier une intervention existante."),
"intervention.assign": ("Affecter une intervention", "Permet d'affecter une intervention à un agent ou une entreprise."),
"intervention.change_status": ("Changer le statut d'une intervention", "Permet de faire évoluer le statut d'une intervention selon son workflow."),
"intervention.close": ("Clôturer une intervention", "Permet de clôturer une intervention terminée et d'enregistrer son état final."),
"intervention.reject": ("Refuser une intervention", "Permet de refuser une demande en enregistrant un motif."),
"patrimoine.view": ("Consulter le patrimoine", "Permet de consulter les bâtiments, zones, locaux et équipements."),
"patrimoine.edit": ("Modifier le patrimoine", "Permet de modifier les informations des éléments du patrimoine."),
"patrimoine.move": ("Déplacer des équipements", "Permet de déplacer des équipements entre les locaux."),
"stock.view": ("Consulter les stocks", "Permet de consulter les produits, lots, quantités et emplacements."),
"stock.receive": ("Réceptionner des produits", "Permet d'enregistrer l'entrée en stock de produits et leurs lots."),
"stock.issue": ("Sortir des produits du stock", "Permet d'enregistrer une sortie de produits vers un agent ou pour utilisation."),
"stock.transfer": ("Transférer du stock", "Permet de déplacer une quantité entre emplacements."),
"stock.inventory": ("Réaliser un inventaire", "Permet d'enregistrer un comptage physique et de traiter les écarts."),
"stock.dilute": ("Préparer une dilution", "Permet d'enregistrer la préparation d'un produit dilué."),
"system.configure": ("Modifier la configuration générale", "Permet de modifier les paramètres généraux de l'application."),
"user.manage": ("Gérer les utilisateurs", "Permet de créer, modifier, désactiver et administrer les comptes."),
"role.manage": ("Gérer les rôles et permissions", "Permet de créer et modifier les rôles et leurs permissions."),
}
def _label(code):
if code in LABELS:
return LABELS[code]
module, _, action = code.partition(".")
return (f"{action.replace('_', ' ').capitalize()}{module.replace('_', ' ').capitalize()}",
f"Permet d'effectuer l'action « {action.replace('_', ' ')} » dans le domaine concerné.")
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 'super_admin','Super administrateur','Rôle système protégé donnant un accès complet à l'application.',1,1,NOW(),NOW()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug='super_admin')"""))
bind.execute(sa.text("""UPDATE roles SET name='Super administrateur', description='Rôle système protégé donnant un accès complet à l'application.', is_system=1, is_active=1 WHERE slug='super_admin'"""))
bind.execute(sa.text("""UPDATE roles SET name='Administrateur', description='Rôle système destiné à ladministration courante de la GMAO. Ses permissions sont configurables.' WHERE slug='admin'"""))
role_text = {
'responsable_gmao': ('Responsable GMAO', 'Rôle système destiné à la supervision et à la gestion générale de la GMAO.'),
'technicien': ('Technicien', 'Rôle système destiné aux opérations de maintenance.'),
'assistant_prevention': ('Assistant de prévention', 'Rôle système donnant accès aux fonctions liées à la prévention.'),
'demandeur': ('Demandeur', 'Rôle système destiné aux utilisateurs pouvant créer et suivre des demandes.'),
'lecture': ('Lecture seule', 'Rôle système destiné aux utilisateurs autorisés uniquement à consulter certaines informations.'),
}
for slug, (name, description) in role_text.items():
bind.execute(sa.text("UPDATE roles SET name=:name, description=:description WHERE slug=:slug"), dict(slug=slug, name=name, description=description))
bind.execute(sa.text("""INSERT INTO permissions (code,name,module,action,description,is_active)
SELECT '*','Accès complet à lapplication','system','all','Accès complet réservé au super administrateur.',1
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code='*')"""))
bind.execute(sa.text("UPDATE permissions SET name='Accès complet à lapplication', description='Accès complet réservé au super administrateur.', is_active=1 WHERE code='*'"))
for code in sorted(PERMISSIONS):
name, description = _label(code)
module, _, action = code.partition('.')
bind.execute(sa.text("""INSERT INTO permissions (code,name,module,action,description,is_active)
SELECT :code,:name,:module,:action,:description,1 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code=:code)"""), dict(code=code, name=name, module=module, action=action, description=description))
bind.execute(sa.text("UPDATE permissions SET name=:name, description=:description, module=:module, action=:action, is_active=1 WHERE code=:code"), dict(code=code, name=name, module=module, action=action, description=description))
# Les permissions historiques conservées reçoivent elles aussi un
# intitulé humain, sans modifier leur code utilisé par le runtime.
for row in bind.execute(sa.text("SELECT code,module,action FROM permissions WHERE code <> '*'" )).all():
name, description = _label(row.code)
bind.execute(sa.text("UPDATE permissions SET name=:name, description=:description WHERE code=:code"), dict(code=row.code, name=name, description=description))
# Le wildcard ne doit plus appartenir à admin.
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN roles r ON r.id=rp.role_id JOIN permissions p ON p.id=rp.permission_id WHERE r.slug='admin' AND p.code='*'"))
bind.execute(sa.text("""INSERT INTO role_permissions (role_id,permission_id,effect)
SELECT r.id,p.id,'allow' FROM roles r JOIN permissions p
WHERE r.slug='super_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)"""))
bind.execute(sa.text("""DELETE rp FROM role_permissions rp JOIN roles r ON r.id=rp.role_id
JOIN permissions p ON p.id=rp.permission_id
WHERE r.slug='admin' AND p.code IN ('intervention.manage','patrimoine.manage','stock.manage')"""))
# Admin reçoit les permissions applicatives explicites (dont les anciennes
# permissions conservées en base), sans le wildcard.
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 p.is_active=1
AND NOT EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role_id=r.id AND rp.permission_id=p.id)"""))
# Continuité d'accès : tous les comptes existants reçoivent le rôle protégé.
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='super_admin'
AND NOT EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id=u.id AND ur.role_id=r.id)"""))
def downgrade():
# Aucun lien utilisateur ni permission ne doit être supprimé par rollback.
pass

View file

@ -1,8 +1,10 @@
from uuid import uuid4
import pytest
from app_new.extensions import db
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
from app_new.core.services.rbac import ensure_super_admin_survives, LastSuperAdminError
def _role_with_permissions(slug, permissions):
@ -53,7 +55,9 @@ def test_admin_removed_keeps_other_roles_without_wildcard(app):
user = _user()
replace_user_roles(user, [admin.id, tech.id, prevention.id])
db.session.commit()
assert has_permission('*', user)
# admin est désormais un rôle explicite : seul super_admin possède '*'.
assert not has_permission('*', user)
assert has_permission('dashboard.view', user)
replace_user_roles(user, [tech.id, prevention.id])
db.session.commit()
assert not has_permission('*', user)
@ -126,3 +130,47 @@ def test_admin_rbac_page_requires_authentication(client):
response = client.get("/admin/roles")
assert response.status_code == 302
assert "/auth/login" in response.location
def test_super_admin_wildcard_and_deny_cannot_reduce_access(app):
with app.app_context():
super_role = Role.query.filter_by(slug='super_admin', is_active=True).first()
user = _user()
replace_user_roles(user, [super_role.id])
db.session.commit()
assert has_permission('*', user)
assert has_permission('stock.destroy', user)
permission = Permission.query.filter_by(code='stock.destroy').first()
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect='deny'))
db.session.commit()
assert has_permission('stock.destroy', user)
def test_admin_is_explicit_and_loses_removed_permission(app):
with app.app_context():
admin = Role.query.filter_by(slug='admin', is_active=True).first()
user = _user()
replace_user_roles(user, [admin.id])
db.session.commit()
assert not has_permission('*', user)
assert has_permission('stock.view', user)
stock = Permission.query.filter_by(code='stock.view').first()
RolePermission.query.filter_by(role_id=admin.id, permission_id=stock.id).delete()
db.session.commit()
assert not has_permission('stock.view', user)
def test_last_super_admin_cannot_be_removed_or_deactivated(app):
with app.app_context():
super_role = Role.query.filter_by(slug='super_admin', is_active=True).first()
# Les tests précédents peuvent avoir créé des comptes temporaires.
for link in list(UserRole.query.filter_by(role_id=super_role.id).all()):
db.session.delete(link)
db.session.flush()
user = _user()
replace_user_roles(user, [super_role.id])
db.session.commit()
with pytest.raises(LastSuperAdminError):
replace_user_roles(user, [])
with pytest.raises(LastSuperAdminError):
ensure_super_admin_survives(user, will_be_active=False)