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.stock import StockError, receive_stock, issue_stock, transfer_stock, transvasement, dilute
from .services.forecast import forecast_product from .services.forecast import forecast_product
from ..core.authorization import permission_required
cleaning_bp = Blueprint("cleaning", __name__, url_prefix="/cleaning", template_folder="templates") cleaning_bp = Blueprint("cleaning", __name__, url_prefix="/cleaning", template_folder="templates")
@ -153,6 +154,7 @@ def locations():
@cleaning_bp.route("/receive", methods=["GET", "POST"]) @cleaning_bp.route("/receive", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.receive")
def receive(): def receive():
products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all() products = ProductGeneric.query.filter_by(is_active=True).order_by(ProductGeneric.name).all()
if request.method == "POST": if request.method == "POST":
@ -167,6 +169,7 @@ def receive():
@cleaning_bp.route("/issue", methods=["GET", "POST"]) @cleaning_bp.route("/issue", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.issue")
def issue(): def issue():
if request.method == "POST": if request.method == "POST":
try: try:
@ -179,6 +182,7 @@ def issue():
@cleaning_bp.route("/transfer", methods=["GET", "POST"]) @cleaning_bp.route("/transfer", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.transfer")
def transfer(): def transfer():
if request.method == "POST": if request.method == "POST":
try: try:
@ -196,6 +200,7 @@ def movements():
@cleaning_bp.route("/transvasement", methods=["GET", "POST"]) @cleaning_bp.route("/transvasement", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.transvasement")
def transvasement_page(): def transvasement_page():
if request.method == "POST": if request.method == "POST":
try: try:
@ -217,6 +222,7 @@ def forecast(product_id):
@cleaning_bp.route("/forecast-config", methods=["GET", "POST"]) @cleaning_bp.route("/forecast-config", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.configure")
def forecast_config(): def forecast_config():
config = CleaningForecastConfig.query.filter_by(is_active=True).first() config = CleaningForecastConfig.query.filter_by(is_active=True).first()
if request.method == "POST": if request.method == "POST":
@ -234,6 +240,7 @@ def forecast_config():
@cleaning_bp.route("/inventory", methods=["GET", "POST"]) @cleaning_bp.route("/inventory", methods=["GET", "POST"])
@login_required @login_required
@permission_required("stock.inventory")
def inventory(): def inventory():
if request.method == "POST": if request.method == "POST":
location = StockLocation.query.get_or_404(int(request.form["location_id"])) 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 = { ROLE_ALIASES = {
# Alias historique conservé uniquement pour l'affichage/compatibilité.
# La sécurité utilise le slug RBAC réel ``super_admin``.
"super_admin": "admin", "super_admin": "admin",
"admin": "admin", "admin": "admin",
"chef": "responsable_gmao", "chef": "responsable_gmao",
@ -28,7 +30,8 @@ ROLE_ALIASES = {
} }
ROLE_LABELS = { ROLE_LABELS = {
"admin": "Administrateur système", "super_admin": "Super administrateur",
"admin": "Administrateur",
"responsable_gmao": "Responsable GMAO", "responsable_gmao": "Responsable GMAO",
"technicien": "Technicien", "technicien": "Technicien",
"assistant_prevention": "Assistant de prévention", "assistant_prevention": "Assistant de prévention",
@ -39,7 +42,27 @@ ROLE_LABELS = {
# LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le # LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le
# bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation. # bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation.
PERMISSIONS_BY_ROLE = { 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": { "responsable_gmao": {
"dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "dashboard.view", "intervention.view", "intervention.create", "intervention.manage",
"patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage",
@ -79,14 +102,10 @@ STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"}
CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"} CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"}
PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"} PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"}
# Catalogue affiché dans la matrice RBAC. Les anciennes permissions # Catalogue réellement utilisé par les routes et les fonctions métier.
# ``*.manage`` restent compatibles et impliquent les actions ci-dessous.
GRANULAR_PERMISSION_CODES = { GRANULAR_PERMISSION_CODES = {
f"{module}.{action}" code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*"
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 |= {
"gmao_config.view", "gmao_config.configure", "gmao_config.view", "gmao_config.configure",
"watchdog_dnd.view", "watchdog_dnd.configure", "watchdog_dnd.view", "watchdog_dnd.configure",
"integration.ent.view", "integration.ent.configure", "integration.ent.view", "integration.ent.configure",
@ -111,6 +130,42 @@ PERMISSION_LABELS = {
"system": "Système", "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): def canonical_role(role):
return ROLE_ALIASES.get((role or "").strip().lower(), "lecture") 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 .models.rbac import RolePermission
from datetime import datetime, timezone from datetime import datetime, timezone
roles = [link.role for link in user.role_links if link.role and link.role.is_active] 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: if roles:
granted = { granted = {
link.permission.code link.permission.code
@ -187,6 +244,22 @@ def required_permission(endpoint, method):
mutating = method not in {"GET", "HEAD", "OPTIONS"} mutating = method not in {"GET", "HEAD", "OPTIONS"}
if blueprint == "cleaning": 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" action = "view"
if mutating: if mutating:
action = "create" if any(token in endpoint for token in ("new", "receive", "issue", "transfer", "transvasement", "inventory")) else "edit" 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": if blueprint == "setup_wizard":
return "system.admin" return "system.admin"
if blueprint in PATRIMOINE_BLUEPRINTS: 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: if blueprint in PLANNING_BLUEPRINTS:
return "planning.manage" if mutating else "planning.view" return "planning.edit" if mutating else "planning.view"
if blueprint == "interventions": 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"}: if endpoint in {"interventions.create", "interventions.create_for_group"}:
return "intervention.create" return "intervention.create"
return "intervention.manage" if mutating else "intervention.view" return "intervention.edit" if mutating else "intervention.view"
if blueprint == "documents": if blueprint == "documents":
if "intervention" in endpoint: if "intervention" in endpoint:
return "intervention.manage" if mutating else "intervention.view" 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 "patrimoine.manage" if mutating else "patrimoine.view"
return "system.admin" return "system.admin"
if blueprint in STOCK_BLUEPRINTS: 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: if blueprint in CONTRACT_BLUEPRINTS:
return "contract.manage" if mutating else "contract.view" return "contract.manage" if mutating else "contract.view"
if blueprint in PREVENTION_BLUEPRINTS: 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.company import Company, Service
from ..models.rbac import Role, Permission, UserRole, RolePermission, UserPermission from ..models.rbac import Role, Permission, UserRole, RolePermission, UserPermission
from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES
from ..authorization import ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS from ..authorization import ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS, permission_metadata
from ..services.rbac import replace_user_roles from ..services.rbac import replace_user_roles, ensure_super_admin_survives, LastSuperAdminError
admin_bp = Blueprint('admin', __name__) 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() legacy_role = Role.query.filter_by(slug=selected_role, is_active=True).first()
selected_role_ids = [legacy_role.id] if legacy_role else [] selected_role_ids = [legacy_role.id] if legacy_role else []
user.email = request.form.get('email') 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') new_password = request.form.get('new_password')
if new_password: if new_password:
@ -125,8 +131,13 @@ def user_edit(user_id):
return render_template('admin/user_form.html', user=user), 400 return render_template('admin/user_form.html', user=user), 400
user.set_password(new_password) user.set_password(new_password)
replace_user_roles(user, selected_role_ids, assigned_by=current_user) try:
db.session.commit() 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') flash('Utilisateur modifié avec succès.', 'success')
return redirect(url_for('admin.users')) return redirect(url_for('admin.users'))
@ -141,7 +152,7 @@ def user_edit(user_id):
def permissions(): def permissions():
"""Vue de synthèse des rôles et de leurs permissions DB.""" """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() 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']) @admin_bp.route('/roles', methods=['GET', 'POST'])
@ -169,6 +180,9 @@ def roles():
@admin_required @admin_required
def role_permissions(role_id): def role_permissions(role_id):
role = Role.query.get_or_404(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() permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all()
if request.method == 'POST': if request.method == 'POST':
selected = {int(value) for value in request.form.getlist('permission_ids')} 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') flash(f'Permissions du rôle « {role.name} » enregistrées.', 'success')
return redirect(url_for('admin.roles')) return redirect(url_for('admin.roles'))
selected = {link.permission_id for link in role.permission_links if link.effect == 'allow'} 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']) @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() permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all()
if request.method == 'POST': if request.method == 'POST':
selected_roles = {int(value) for value in request.form.getlist('role_ids')} 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 # Les exceptions explicites sont optionnelles et tracées ; une case
# « refuser » est prioritaire sur les rôles. # « refuser » est prioritaire sur les rôles.
UserPermission.query.filter_by(user_id=user.id).delete(synchronize_session=False) 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')) return redirect(url_for('admin.users'))
selected_roles = {link.role_id for link in user.role_links} selected_roles = {link.role_id for link in user.role_links}
overrides = {link.permission_id: link.effect for link in user.permission_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') @admin_bp.route('/audit')
@ -267,8 +301,14 @@ def user_delete(user_id):
if user.id == current_user.id: if user.id == current_user.id:
flash('Vous ne pouvez pas supprimer votre propre compte.', 'danger') flash('Vous ne pouvez pas supprimer votre propre compte.', 'danger')
else: else:
db.session.delete(user) try:
db.session.commit() 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') flash('Utilisateur supprimé.', 'success')
return redirect(url_for('admin.users')) 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 ...extensions import db
from ..models.rbac import Role, UserRole 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): 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 {} } if normalized_ids else {}
selected_ids = set(active_roles) 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)} existing = {link.role_id: link for link in list(user.role_links)}
for role_id, link in existing.items(): for role_id, link in existing.items():
if role_id not in selected_ids: 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)] 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, confirm_disposal, propose_disposal, record_event, start_repair as start_equipment_repair,
update_future_task_location, update_future_task_location,
) )
from ..core.authorization import permission_required
def _empty_to_none(v): def _empty_to_none(v):
"""Convertit une chaîne vide en None.""" """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']) @main_bp.route('/<int:id>/move', methods=['GET', 'POST'])
@login_required @login_required
@permission_required('patrimoine.move')
def move_to_room(id): def move_to_room(id):
"""Déplace une unité ou une quantité, sans casser les totaux des groupes.""" """Déplace une unité ou une quantité, sans casser les totaux des groupes."""
equipment = Equipment.query.get_or_404(id) 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']) @main_bp.route('/<int:id>/repair/start', methods=['POST'])
@login_required @login_required
@permission_required('patrimoine.retire')
def start_repair(id): def start_repair(id):
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
quantity = request.form.get('quantity', 1, type=int) or 1 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']) @main_bp.route('/<int:id>/repair/complete', methods=['POST'])
@login_required @login_required
@permission_required('patrimoine.edit')
def complete_repair(id): def complete_repair(id):
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
complete_equipment_repair(equipment, request.form.get('reason'), current_user.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.college import Building, Zone, Room
from ..core.models.company import Service, Company from ..core.models.company import Service, Company
from app_new.constants import INTERVENTION_STATUSES, INTERVENTION_TRANSITIONS, PRIORITIES, WORKFLOW_TYPES, WORKFLOW_STATUS_ORDERS 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') interventions_bp = Blueprint('interventions', __name__, template_folder='templates')
@ -120,6 +121,7 @@ def index():
@interventions_bp.route('/new', methods=['GET', 'POST']) @interventions_bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@permission_required('intervention.create')
def create(): def create():
"""Créer une intervention.""" """Créer une intervention."""
from ..core.models.user import User from ..core.models.user import User
@ -490,6 +492,7 @@ def update_location(id):
@interventions_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @interventions_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@permission_required('intervention.edit')
def edit(id): def edit(id):
"""Modifier une intervention.""" """Modifier une intervention."""
from ..core.models.user import User from ..core.models.user import User
@ -566,6 +569,7 @@ def edit(id):
@interventions_bp.route('/<int:id>/status', methods=['POST']) @interventions_bp.route('/<int:id>/status', methods=['POST'])
@login_required @login_required
@permission_required('intervention.change_status')
def change_status(id): def change_status(id):
"""Changer le statut d'une intervention.""" """Changer le statut d'une intervention."""
intervention = Intervention.query.get_or_404(id) intervention = Intervention.query.get_or_404(id)
@ -580,6 +584,9 @@ def change_status(id):
if new_status not in workflow_statuses: if new_status not in workflow_statuses:
flash(f'Le statut « {new_status} » ne correspond pas au workflow {intervention.workflow_label}.', 'danger') flash(f'Le statut « {new_status} » ne correspond pas au workflow {intervention.workflow_label}.', 'danger')
return redirect(url_for('interventions.detail', id=intervention.id)) 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: if new_status != old_status and new_status not in allowed:
flash(f'Transition interdite : {old_status}{new_status}.', 'danger') flash(f'Transition interdite : {old_status}{new_status}.', 'danger')
return redirect(url_for('interventions.detail', id=intervention.id)) 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']) @interventions_bp.route('/<int:id>/refuse', methods=['POST'])
@login_required @login_required
@permission_required('intervention.reject')
def refuse(id): def refuse(id):
"""Refuse une demande avec un motif obligatoire.""" """Refuse une demande avec un motif obligatoire."""
intervention = Intervention.query.get_or_404(id) intervention = Intervention.query.get_or_404(id)
@ -641,6 +649,7 @@ def refuse(id):
@interventions_bp.route('/<int:id>/comment', methods=['POST']) @interventions_bp.route('/<int:id>/comment', methods=['POST'])
@login_required @login_required
@permission_required('intervention.comment')
def add_comment(id): def add_comment(id):
"""Ajouter un commentaire à une intervention.""" """Ajouter un commentaire à une intervention."""
intervention = Intervention.query.get_or_404(id) intervention = Intervention.query.get_or_404(id)
@ -661,6 +670,7 @@ def add_comment(id):
@interventions_bp.route('/<int:id>/soft-delete', methods=['POST']) @interventions_bp.route('/<int:id>/soft-delete', methods=['POST'])
@login_required @login_required
@permission_required('intervention.delete')
def soft_delete(id): def soft_delete(id):
"""Mettre une intervention en corbeille (soft delete).""" """Mettre une intervention en corbeille (soft delete)."""
intervention = Intervention.query.get_or_404(id) intervention = Intervention.query.get_or_404(id)
@ -674,6 +684,7 @@ def soft_delete(id):
@interventions_bp.route('/<int:id>/postpone', methods=['POST']) @interventions_bp.route('/<int:id>/postpone', methods=['POST'])
@login_required @login_required
@permission_required('intervention.postpone')
def postpone(id): def postpone(id):
"""Reporter une intervention : motif, compteur, conservation date originale.""" """Reporter une intervention : motif, compteur, conservation date originale."""
intervention = Intervention.query.get_or_404(id) 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 class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3"><i class="bi bi-shield-check"></i> Rôles et permissions</h1> <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> </div>
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary"><i class="bi bi-people"></i> Utilisateurs</a> <a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary"><i class="bi bi-people"></i> Utilisateurs</a>
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
Les permissions ne sont pas modifiables case par case : cela évite les droits invisibles et les écarts entre écrans. Les rôles peuvent cumuler plusieurs permissions. Le code technique reste affiché à titre secondaire pour le diagnostic.
Toute évolution de la matrice est versionnée et testée.
</div> </div>
<div class="row g-3"> <div class="row g-3">
@ -26,16 +25,18 @@
<span class="badge bg-secondary">{{ role.user_links|length }} compte(s)</span> <span class="badge bg-secondary">{{ role.user_links|length }} compte(s)</span>
</div> </div>
<div class="card-body"> <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 %} {% if role.permission_links|selectattr('permission.code', 'equalto', '*')|list %}
<span class="badge bg-danger">Accès complet</span> <span class="badge bg-danger">Accès complet</span>
{% else %} {% else %}
<div class="d-flex flex-wrap gap-1 mb-3"> <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 %} {% 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 %} {% endfor %}
</div> </div>
{% endif %} {% endif %}{% endif %}
{% if role.user_links %} {% if role.user_links %}
<hr> <hr>

View file

@ -3,7 +3,9 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<h1>Permissions du rôle « {{ role.name }} »</h1> <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"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for module, permissions in permissions_list|groupby('module') %} {% for module, permissions in permissions_list|groupby('module') %}
@ -20,8 +22,9 @@
<div class="col-xl-3 col-md-4"> <div class="col-xl-3 col-md-4">
<label class="border rounded p-2 d-block h-100"> <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 %}> <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> <strong>{{ permission.name }}</strong><br>
<small class="text-muted">{{ permission.name }}</small> <small class="text-muted">{{ permission.description }}</small><br>
<small class="text-secondary">Code : {{ permission.code }}</small>
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
@ -33,6 +36,7 @@
<button class="btn btn-primary">Enregistrer les permissions</button> <button class="btn btn-primary">Enregistrer les permissions</button>
<a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.roles') }}">Annuler</a> <a class="btn btn-outline-secondary ms-2" href="{{ url_for('admin.roles') }}">Annuler</a>
</form> </form>
{% endif %}
</div> </div>
<style> <style>
.permission-section[open] .section-chevron { transform: rotate(90deg); } .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>{{ user.email }}</td>
<td> <td>
{% if user.is_admin() %}<span class="badge bg-danger">Administrateur</span> {% 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 %} {% else %}<span class="badge bg-secondary">{{ user.role_label }}</span>{% endif %}
</td> </td>
<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 from uuid import uuid4
import pytest
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models import User, Role, Permission, RolePermission, UserPermission, UserRole from app_new.core.models import User, Role, Permission, RolePermission, UserPermission, UserRole
from app_new.core.authorization import has_permission 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 replace_user_roles
from app_new.core.services.rbac import ensure_super_admin_survives, LastSuperAdminError
def _role_with_permissions(slug, permissions): def _role_with_permissions(slug, permissions):
@ -53,7 +55,9 @@ def test_admin_removed_keeps_other_roles_without_wildcard(app):
user = _user() user = _user()
replace_user_roles(user, [admin.id, tech.id, prevention.id]) replace_user_roles(user, [admin.id, tech.id, prevention.id])
db.session.commit() 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]) replace_user_roles(user, [tech.id, prevention.id])
db.session.commit() db.session.commit()
assert not has_permission('*', user) assert not has_permission('*', user)
@ -126,3 +130,47 @@ def test_admin_rbac_page_requires_authentication(client):
response = client.get("/admin/roles") response = client.get("/admin/roles")
assert response.status_code == 302 assert response.status_code == 302
assert "/auth/login" in response.location 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)