Renforcer le RBAC et séparer les administrateurs
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
ee8c0ad5a0
commit
20362fb31e
12 changed files with 445 additions and 37 deletions
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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')}
|
||||
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:
|
||||
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'))
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 d’un 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); }
|
||||
|
|
|
|||
|
|
@ -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 l’union 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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
119
migrations/versions/9f4a1b2c3d5e_separer_super_admin_et_admin.py
Normal file
119
migrations/versions/9f4a1b2c3d5e_separer_super_admin_et_admin.py
Normal 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é à l’administration 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 à l’application','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 à l’application', 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue