Refondre le RBAC multi-roles et securiser les acces
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
fec702ce38
commit
785c8aabcb
38 changed files with 506 additions and 633 deletions
|
|
@ -11,27 +11,33 @@ from app_new.extensions import db
|
|||
api_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
def api_key_required(f):
|
||||
def api_key_required(permission=None):
|
||||
"""Authentification par clé API (header X-API-Key) ou session login."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# Accepter soit un header X-API-Key, soit une session Flask-Login
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key:
|
||||
from app_new.core.models.settings import AppSettings
|
||||
api_key_hash = AppSettings.get('api_key_hash')
|
||||
if api_key_hash and check_password_hash(api_key_hash, api_key):
|
||||
# La clé historique reste une clé de service à accès complet.
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({'error': 'Clé API invalide'}), 401
|
||||
# Sinon, fallback sur session login
|
||||
if current_user.is_authenticated:
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({'error': 'Authentification requise (X-API-Key ou login)'}), 401
|
||||
return decorated
|
||||
if not current_user.is_authenticated:
|
||||
return jsonify({'error': 'Authentification requise (X-API-Key ou login)'}), 401
|
||||
from app_new.core.authorization import has_permission
|
||||
if permission and not has_permission(permission, current_user):
|
||||
return jsonify({'error': 'Permission insuffisante', 'permission': permission}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
@api_bp.route('/status')
|
||||
@api_key_required
|
||||
@api_key_required('dashboard.view')
|
||||
def status():
|
||||
"""État global du système."""
|
||||
from app_new.core.models.maintenance import Intervention
|
||||
|
|
@ -50,7 +56,7 @@ def status():
|
|||
|
||||
|
||||
@api_bp.route('/interventions')
|
||||
@api_key_required
|
||||
@api_key_required('intervention.view')
|
||||
def list_interventions():
|
||||
"""Liste des interventions avec pagination."""
|
||||
from app_new.core.models.maintenance import Intervention
|
||||
|
|
@ -87,7 +93,7 @@ def list_interventions():
|
|||
|
||||
|
||||
@api_bp.route('/interventions/<int:intervention_id>')
|
||||
@api_key_required
|
||||
@api_key_required('intervention.view')
|
||||
def get_intervention(intervention_id):
|
||||
"""Détail d'une intervention."""
|
||||
from app_new.core.models.maintenance import Intervention
|
||||
|
|
@ -111,7 +117,7 @@ def get_intervention(intervention_id):
|
|||
|
||||
|
||||
@api_bp.route('/equipments')
|
||||
@api_key_required
|
||||
@api_key_required('patrimoine.view')
|
||||
def list_equipments():
|
||||
"""Liste des équipements avec pagination."""
|
||||
from app_new.core.models.equipment import Equipment
|
||||
|
|
@ -144,7 +150,7 @@ def list_equipments():
|
|||
|
||||
|
||||
@api_bp.route('/equipments/<int:equipment_id>')
|
||||
@api_key_required
|
||||
@api_key_required('patrimoine.view')
|
||||
def get_equipment(equipment_id):
|
||||
"""Détail d'un équipement."""
|
||||
from app_new.core.models.equipment import Equipment
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ retombe plus sur le simple fait d'être connecté.
|
|||
"""
|
||||
from functools import wraps
|
||||
|
||||
from flask import abort, request
|
||||
from flask import abort, request, redirect, url_for, jsonify, current_app
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ ROLE_LABELS = {
|
|||
"lecture": "Lecture seule",
|
||||
}
|
||||
|
||||
# LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le
|
||||
# bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation.
|
||||
PERMISSIONS_BY_ROLE = {
|
||||
"admin": {"*"},
|
||||
"responsable_gmao": {
|
||||
|
|
@ -115,45 +117,55 @@ def canonical_role(role):
|
|||
|
||||
|
||||
def has_permission(permission, user=None):
|
||||
"""Retourne le droit effectif, en mode fail-closed.
|
||||
|
||||
La matrice SQL est la seule source de vérité à l'exécution : tous les
|
||||
rôles actifs sont réunis, puis les exceptions individuelles sont appliquées
|
||||
avec priorité aux refus. Aucune valeur de ``User.role`` n'est consultée.
|
||||
"""
|
||||
user = user or current_user
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
# Les rôles configurables sont prioritaires lorsqu'ils existent. Le
|
||||
# fallback statique conserve la compatibilité avec les anciennes bases
|
||||
# avant application de la migration RBAC.
|
||||
try:
|
||||
from .models.rbac import Role, RolePermission, Permission, UserPermission
|
||||
from .models.rbac import RolePermission
|
||||
from datetime import datetime, timezone
|
||||
roles = [link.role for link in user.role_links if link.role and link.role.is_active]
|
||||
legacy_role = Role.query.filter_by(slug=user.role, is_active=True).first()
|
||||
if legacy_role and legacy_role not in roles:
|
||||
roles.append(legacy_role)
|
||||
if roles:
|
||||
granted = {link.permission.code for role in roles for link in role.permission_links
|
||||
if link.effect == "allow" and link.permission.is_active}
|
||||
denied = set()
|
||||
for override in user.permission_links:
|
||||
now = datetime.now(timezone.utc)
|
||||
expiry = override.expires_at
|
||||
if expiry and expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
if expiry and expiry < now:
|
||||
continue
|
||||
(granted if override.effect == "allow" else denied).add(override.permission.code)
|
||||
if permission in denied:
|
||||
return False
|
||||
if permission in granted or "*" in granted:
|
||||
return True
|
||||
module = permission.rsplit(".", 1)[0] if "." in permission else permission
|
||||
return f"{module}.manage" in granted and f"{module}.manage" not in denied
|
||||
granted = {
|
||||
link.permission.code
|
||||
for role in roles
|
||||
for link in role.permission_links
|
||||
if link.effect == "allow" and link.permission and link.permission.is_active
|
||||
}
|
||||
else:
|
||||
granted = set()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
allowed_overrides = set()
|
||||
denied = set()
|
||||
for override in user.permission_links:
|
||||
expiry = override.expires_at
|
||||
if expiry and expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
if expiry and expiry < now:
|
||||
continue
|
||||
if not override.permission or not override.permission.is_active:
|
||||
continue
|
||||
if override.effect == "deny":
|
||||
denied.add(override.permission.code)
|
||||
elif override.effect == "allow":
|
||||
allowed_overrides.add(override.permission.code)
|
||||
|
||||
granted.update(allowed_overrides)
|
||||
if permission in denied:
|
||||
return False
|
||||
if permission in granted or "*" in granted:
|
||||
return True
|
||||
module = permission.rsplit(".", 1)[0] if "." in permission else permission
|
||||
return f"{module}.manage" in granted and f"{module}.manage" not in denied
|
||||
except Exception:
|
||||
# Une base non migrée ne doit pas rendre toutes les routes indisponibles.
|
||||
pass
|
||||
permissions = PERMISSIONS_BY_ROLE.get(canonical_role(user.role), set())
|
||||
if "*" in permissions or permission in permissions:
|
||||
return True
|
||||
module = permission.rsplit(".", 1)[0] if "." in permission else permission
|
||||
return f"{module}.manage" in permissions
|
||||
current_app.logger.exception("Erreur RBAC lors du calcul de %s pour l'utilisateur %s", permission, getattr(user, "id", None))
|
||||
return False
|
||||
|
||||
|
||||
def permission_required(permission):
|
||||
|
|
@ -246,7 +258,9 @@ def enforce_route_permission():
|
|||
if endpoint and endpoint.startswith("api_v1."):
|
||||
return None
|
||||
if not current_user.is_authenticated:
|
||||
return None # Flask-Login conserve la redirection habituelle des vues protégées.
|
||||
if request.path.startswith('/api/') or request.is_json or request.accept_mimetypes.best == 'application/json':
|
||||
return jsonify({'error': 'Authentification requise'}), 401
|
||||
return redirect(url_for('auth.login', next=request.full_path.rstrip('?')))
|
||||
permission = required_permission(endpoint, request.method)
|
||||
if permission and not has_permission(permission):
|
||||
abort(403)
|
||||
|
|
|
|||
|
|
@ -34,9 +34,24 @@ class User(UserMixin, db.Model):
|
|||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def is_admin(self):
|
||||
"""Vérifie si l'utilisateur est admin."""
|
||||
from ..authorization import canonical_role
|
||||
return canonical_role(self.role) == "admin"
|
||||
"""Vérifie l'administration via le RBAC réel, jamais ``User.role``."""
|
||||
try:
|
||||
for link in self.role_links:
|
||||
if link.role and link.role.is_active and link.role.slug == "admin":
|
||||
return True
|
||||
if link.role and link.role.is_active:
|
||||
if any(
|
||||
permission.effect == "allow"
|
||||
and permission.permission
|
||||
and permission.permission.is_active
|
||||
and permission.permission.code == "*"
|
||||
for permission in link.role.permission_links
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
from flask import current_app
|
||||
current_app.logger.exception("Erreur RBAC lors de la vérification admin")
|
||||
return False
|
||||
|
||||
@property
|
||||
def canonical_role(self):
|
||||
|
|
@ -46,7 +61,10 @@ class User(UserMixin, db.Model):
|
|||
@property
|
||||
def role_label(self):
|
||||
from ..authorization import ROLE_LABELS
|
||||
return ROLE_LABELS.get(self.canonical_role, self.role or "—")
|
||||
roles = [link.role for link in self.role_links if link.role and link.role.is_active]
|
||||
if roles:
|
||||
return ", ".join(role.name for role in sorted(roles, key=lambda item: item.name.lower()))
|
||||
return "Aucun rôle RBAC"
|
||||
|
||||
def has_permission(self, permission):
|
||||
from ..authorization import has_permission
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ from ..models.college import College, Building, Room, RoomType
|
|||
from ..models.company import Company, Service
|
||||
from ..models.rbac import Role, Permission, UserRole, RolePermission, UserPermission
|
||||
from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES
|
||||
from ..authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS
|
||||
from ..authorization import ROLE_LABELS, canonical_role, has_permission, required_permission, PERMISSION_LABELS
|
||||
from ..services.rbac import replace_user_roles
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ def admin_required(f):
|
|||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
required = required_permission(request.endpoint, request.method)
|
||||
if not current_user.is_authenticated or (not current_user.is_admin() and not has_permission(required)):
|
||||
if not current_user.is_authenticated or not has_permission(required):
|
||||
flash('Accès réservé aux administrateurs.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return f(*args, **kwargs)
|
||||
|
|
@ -69,8 +70,11 @@ def user_new():
|
|||
email = (request.form.get('email') or '').strip()
|
||||
password = request.form.get('password') or ''
|
||||
password_confirm = request.form.get('password_confirm') or ''
|
||||
role = request.form.get('role', 'demandeur')
|
||||
valid_roles = set(ROLES) | {r.slug for r in Role.query.filter_by(is_active=True).all()}
|
||||
selected_role_ids = request.form.getlist('role_ids')
|
||||
selected_role = request.form.get('role')
|
||||
if not selected_role_ids and selected_role:
|
||||
legacy_role = Role.query.filter_by(slug=selected_role, is_active=True).first()
|
||||
selected_role_ids = [legacy_role.id] if legacy_role else []
|
||||
|
||||
if len(username) < 3:
|
||||
flash('Le nom d’utilisateur doit contenir au moins 3 caractères.', 'danger')
|
||||
|
|
@ -80,26 +84,22 @@ def user_new():
|
|||
flash('Le mot de passe doit contenir au moins 12 caractères.', 'danger')
|
||||
elif password != password_confirm:
|
||||
flash('Les mots de passe ne correspondent pas.', 'danger')
|
||||
elif role not in valid_roles:
|
||||
flash('Rôle utilisateur invalide.', 'danger')
|
||||
elif User.query.filter_by(username=username).first():
|
||||
flash('Ce nom d\'utilisateur existe déjà.', 'danger')
|
||||
elif User.query.filter_by(email=email).first():
|
||||
flash('Cet email existe déjà.', 'danger')
|
||||
else:
|
||||
user = User(username=username, email=email, role=role)
|
||||
user = User(username=username, email=email, role='demandeur')
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
selected_role = Role.query.filter_by(slug=role, is_active=True).first()
|
||||
if selected_role:
|
||||
db.session.add(UserRole(user_id=user.id, role_id=selected_role.id, assigned_by_id=current_user.id))
|
||||
replace_user_roles(user, selected_role_ids, assigned_by=current_user)
|
||||
db.session.commit()
|
||||
flash('Utilisateur créé avec succès.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
roles = {**ROLES, **{r.slug: r.name for r in Role.query.filter_by(is_active=True).all()}}
|
||||
return render_template('admin/user_form.html', roles=roles)
|
||||
roles_list = Role.query.filter_by(is_active=True).order_by(Role.name).all()
|
||||
return render_template('admin/user_form.html', roles_list=roles_list, selected_roles=set())
|
||||
|
||||
|
||||
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
|
|
@ -110,13 +110,12 @@ def user_edit(user_id):
|
|||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
role = request.form.get('role')
|
||||
valid_roles = set(ROLES) | {r.slug for r in Role.query.filter_by(is_active=True).all()}
|
||||
if role not in valid_roles:
|
||||
flash('Rôle utilisateur invalide.', 'danger')
|
||||
return render_template('admin/user_form.html', user=user, roles=ROLES), 400
|
||||
selected_role_ids = request.form.getlist('role_ids')
|
||||
selected_role = request.form.get('role')
|
||||
if not selected_role_ids and selected_role:
|
||||
legacy_role = Role.query.filter_by(slug=selected_role, is_active=True).first()
|
||||
selected_role_ids = [legacy_role.id] if legacy_role else []
|
||||
user.email = request.form.get('email')
|
||||
user.role = role
|
||||
user.is_active = request.form.get('is_active') == 'on'
|
||||
|
||||
new_password = request.form.get('new_password')
|
||||
|
|
@ -126,30 +125,23 @@ def user_edit(user_id):
|
|||
return render_template('admin/user_form.html', user=user), 400
|
||||
user.set_password(new_password)
|
||||
|
||||
selected_role = Role.query.filter_by(slug=role, is_active=True).first()
|
||||
if selected_role and not UserRole.query.filter_by(user_id=user.id, role_id=selected_role.id).first():
|
||||
db.session.add(UserRole(user_id=user.id, role_id=selected_role.id, assigned_by_id=current_user.id))
|
||||
replace_user_roles(user, selected_role_ids, assigned_by=current_user)
|
||||
db.session.commit()
|
||||
flash('Utilisateur modifié avec succès.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
roles = {**ROLES, **{r.slug: r.name for r in Role.query.filter_by(is_active=True).all()}}
|
||||
return render_template('admin/user_form.html', user=user, roles=roles)
|
||||
roles_list = Role.query.filter_by(is_active=True).order_by(Role.name).all()
|
||||
selected_roles = {link.role_id for link in user.role_links if link.role and link.role.is_active}
|
||||
return render_template('admin/user_form.html', user=user, roles_list=roles_list, selected_roles=selected_roles)
|
||||
|
||||
|
||||
@admin_bp.route('/permissions')
|
||||
@login_required
|
||||
@admin_required
|
||||
def permissions():
|
||||
"""Liste des permissions."""
|
||||
from ..models.user import User
|
||||
users_by_role = {role: [] for role in ROLES}
|
||||
for user in User.query.order_by(User.full_name).all():
|
||||
users_by_role[canonical_role(user.role)].append(user)
|
||||
return render_template(
|
||||
'admin/permissions.html', roles=ROLES, role_labels=ROLE_LABELS,
|
||||
users_by_role=users_by_role, permissions_by_role=PERMISSIONS_BY_ROLE,
|
||||
)
|
||||
"""Vue de synthèse des rôles et de leurs permissions DB."""
|
||||
roles_list = Role.query.order_by(Role.is_system.desc(), Role.name).all()
|
||||
return render_template('admin/permissions.html', roles_list=roles_list)
|
||||
|
||||
|
||||
@admin_bp.route('/roles', methods=['GET', 'POST'])
|
||||
|
|
@ -214,10 +206,7 @@ def user_access(user_id):
|
|||
permissions_list = Permission.query.filter_by(is_active=True).order_by(Permission.module, Permission.action, Permission.code).all()
|
||||
if request.method == 'POST':
|
||||
selected_roles = {int(value) for value in request.form.getlist('role_ids')}
|
||||
UserRole.query.filter_by(user_id=user.id).delete(synchronize_session=False)
|
||||
for role in roles_list:
|
||||
if role.id in selected_roles:
|
||||
db.session.add(UserRole(user_id=user.id, role_id=role.id, assigned_by_id=current_user.id))
|
||||
replace_user_roles(user, selected_roles, assigned_by=current_user)
|
||||
# Les exceptions explicites sont optionnelles et tracées ; une case
|
||||
# « refuser » est prioritaire sur les rôles.
|
||||
UserPermission.query.filter_by(user_id=user.id).delete(synchronize_session=False)
|
||||
|
|
@ -225,8 +214,6 @@ def user_access(user_id):
|
|||
effect = request.form.get(f'permission_{permission.id}')
|
||||
if effect in ('allow', 'deny'):
|
||||
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect=effect, note='Exception administrateur'))
|
||||
if selected_roles:
|
||||
user.role = next((r.slug for r in roles_list if r.id in selected_roles), user.role)
|
||||
db.session.commit()
|
||||
flash(f'Accès de {user.username} enregistrés.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
|
@ -250,15 +237,15 @@ def audit_logs():
|
|||
@login_required
|
||||
@admin_required
|
||||
def update_user_permissions(id):
|
||||
"""Change le rôle d'un utilisateur (les permissions sont définies en code)."""
|
||||
"""Compatibilité : remplace l'ensemble des rôles sélectionnés."""
|
||||
user = User.query.get_or_404(id)
|
||||
role = request.form.get('role')
|
||||
if role not in ROLES:
|
||||
flash('Rôle invalide.', 'danger')
|
||||
return redirect(url_for('admin.permissions'))
|
||||
user.role = role
|
||||
role_ids = request.form.getlist('role_ids')
|
||||
if not role_ids and request.form.get('role'):
|
||||
role = Role.query.filter_by(slug=request.form.get('role'), is_active=True).first()
|
||||
role_ids = [role.id] if role else []
|
||||
replace_user_roles(user, role_ids, assigned_by=current_user)
|
||||
db.session.commit()
|
||||
flash(f'Rôle de {user.username} mis à jour.', 'success')
|
||||
flash(f'Rôles de {user.username} mis à jour.', 'success')
|
||||
return redirect(url_for('admin.permissions'))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ from ...extensions import db
|
|||
from ..models.user import User
|
||||
from ..forms import LoginForm, UserForm, PasswordForm
|
||||
from ...constants import ROLES
|
||||
from ..models.rbac import Role
|
||||
from ..services.rbac import replace_user_roles
|
||||
from ..authorization import has_permission
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
|
@ -49,7 +52,7 @@ def logout():
|
|||
@login_required
|
||||
def list_users():
|
||||
"""Liste des utilisateurs."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('user.view', current_user):
|
||||
flash("Accès réservé aux administrateurs.", "danger")
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
|
|
@ -61,12 +64,13 @@ def list_users():
|
|||
@login_required
|
||||
def create_user():
|
||||
"""Créer un utilisateur."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('user.create', current_user):
|
||||
flash("Accès réservé aux administrateurs.", "danger")
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = UserForm()
|
||||
if form.validate_on_submit():
|
||||
selected_role_slugs = request.form.getlist('role_ids') or ([form.role.data] if form.role.data else [])
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data,
|
||||
|
|
@ -80,11 +84,14 @@ def create_user():
|
|||
user.set_password(form.password.data)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
selected_roles = Role.query.filter(Role.slug.in_(selected_role_slugs), Role.is_active.is_(True)).all() if selected_role_slugs else []
|
||||
replace_user_roles(user, [role.id for role in selected_roles], assigned_by=current_user)
|
||||
db.session.commit()
|
||||
flash(f"Utilisateur {user.full_name} créé.", "success")
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, title="Nouvel utilisateur")
|
||||
return render_template('auth/user_form.html', form=form, title="Nouvel utilisateur", roles_list=Role.query.filter_by(is_active=True).order_by(Role.name).all())
|
||||
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
|
|
@ -98,7 +105,7 @@ def profile():
|
|||
@login_required
|
||||
def edit_user(id):
|
||||
"""Modifier un utilisateur."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('user.edit', current_user):
|
||||
flash("Accès réservé aux administrateurs.", "danger")
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
|
|
@ -106,20 +113,21 @@ def edit_user(id):
|
|||
form = UserForm(obj=user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
selected_role_slugs = request.form.getlist('role_ids') or ([form.role.data] if form.role.data else [])
|
||||
user.username = form.username.data
|
||||
user.email = form.email.data
|
||||
user.full_name = form.full_name.data
|
||||
user.role = form.role.data
|
||||
user.is_active = form.is_active.data
|
||||
|
||||
if form.password.data:
|
||||
user.set_password(form.password.data)
|
||||
|
||||
selected_roles = Role.query.filter(Role.slug.in_(selected_role_slugs), Role.is_active.is_(True)).all() if selected_role_slugs else []
|
||||
replace_user_roles(user, [role.id for role in selected_roles], assigned_by=current_user)
|
||||
db.session.commit()
|
||||
flash(f"Utilisateur {user.full_name} modifié.", "success")
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, title="Modifier l'utilisateur")
|
||||
return render_template('auth/user_form.html', form=form, title="Modifier l'utilisateur", roles_list=Role.query.filter_by(is_active=True).order_by(Role.name).all())
|
||||
|
||||
|
||||
@auth_bp.route('/change-password', methods=['GET', 'POST'])
|
||||
|
|
@ -144,7 +152,7 @@ def change_own_password():
|
|||
@login_required
|
||||
def delete_user(id):
|
||||
"""Supprimer un utilisateur."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('user.delete', current_user):
|
||||
flash("Accès réservé aux administrateurs.", "danger")
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
|
|
@ -163,7 +171,7 @@ def delete_user(id):
|
|||
@login_required
|
||||
def change_user_password(id):
|
||||
"""Changer le mot de passe d'un utilisateur (admin only)."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('user.edit', current_user):
|
||||
flash("Accès réservé aux administrateurs.", "danger")
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ from ..models.user import User
|
|||
from ..models.college import College, Building, Zone, Room
|
||||
from ..models.equipment import EquipmentCategory
|
||||
from ..models.maintenance import Lot
|
||||
from ..models.rbac import Role
|
||||
from ..services.rbac import replace_user_roles
|
||||
from ..authorization import has_permission
|
||||
from ..models.planning import WorkSchedule, CollegeClosure, ClosureWorkDay
|
||||
from ..setup_catalog import EQUIPMENT_CATEGORIES, LOTS, grouped_catalog
|
||||
|
||||
|
|
@ -256,7 +259,6 @@ def api_save_step(step):
|
|||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
user.set_password(password)
|
||||
user.role = 'admin'
|
||||
user.is_active = True
|
||||
else:
|
||||
user = User(
|
||||
|
|
@ -266,7 +268,12 @@ def api_save_step(step):
|
|||
is_active=True
|
||||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
admin_role = Role.query.filter_by(slug='admin', is_active=True).first()
|
||||
if admin_role:
|
||||
replace_user_roles(user, [admin_role.id])
|
||||
|
||||
progress.admin_username = username
|
||||
progress.step = 2
|
||||
|
|
@ -629,7 +636,7 @@ def api_test_pronote():
|
|||
@login_required
|
||||
def admin_panel():
|
||||
"""Panneau d'administration."""
|
||||
if not current_user.is_admin():
|
||||
if not has_permission('system.configure', current_user):
|
||||
abort(403)
|
||||
from ..models.user import User
|
||||
from ..models.college import Building, Room
|
||||
|
|
|
|||
45
app_new/core/services/rbac.py
Normal file
45
app_new/core/services/rbac.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Services transactionnels pour la gestion des rôles RBAC.
|
||||
|
||||
Les fonctions de ce module ne valident ni ne commitent la transaction appelante.
|
||||
Cela permet aux routes d'enregistrer simultanément l'utilisateur et ses accès.
|
||||
"""
|
||||
|
||||
from ...extensions import db
|
||||
from ..models.rbac import Role, UserRole
|
||||
|
||||
|
||||
def replace_user_roles(user, role_ids, assigned_by=None):
|
||||
"""Remplace l'ensemble des rôles actifs d'un utilisateur.
|
||||
|
||||
Les liens déjà sélectionnés sont conservés (et donc leur traçabilité), les
|
||||
liens absents sont retirés et les nouveaux sont ajoutés sans doublon.
|
||||
``role_ids`` peut être vide. Les rôles inactifs ou inconnus sont ignorés.
|
||||
La fonction travaille dans la transaction courante et ne committe jamais.
|
||||
"""
|
||||
normalized_ids = set()
|
||||
for value in role_ids or ():
|
||||
try:
|
||||
normalized_ids.add(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
active_roles = {
|
||||
role.id: role
|
||||
for role in Role.query.filter(Role.id.in_(normalized_ids), Role.is_active.is_(True)).all()
|
||||
} if normalized_ids else {}
|
||||
selected_ids = set(active_roles)
|
||||
|
||||
existing = {link.role_id: link for link in list(user.role_links)}
|
||||
for role_id, link in existing.items():
|
||||
if role_id not in selected_ids:
|
||||
db.session.delete(link)
|
||||
|
||||
for role_id in selected_ids - set(existing):
|
||||
db.session.add(UserRole(
|
||||
user_id=user.id,
|
||||
role_id=role_id,
|
||||
assigned_by_id=getattr(assigned_by, "id", assigned_by),
|
||||
))
|
||||
|
||||
return [active_roles[role_id] for role_id in sorted(selected_ids)]
|
||||
|
||||
|
|
@ -3,6 +3,7 @@ from functools import wraps
|
|||
|
||||
from flask import abort
|
||||
from flask_login import current_user, login_required
|
||||
from .authorization import has_permission
|
||||
|
||||
|
||||
def system_admin_required(view):
|
||||
|
|
@ -10,7 +11,7 @@ def system_admin_required(view):
|
|||
@wraps(view)
|
||||
@login_required
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_admin():
|
||||
if not has_permission("system.configure", current_user):
|
||||
abort(403)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,11 +60,11 @@
|
|||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Permission code:edit :</strong>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="badge bg-success">✅ Accordée</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">❌ Refusée</span>
|
||||
|
|
@ -130,4 +130,4 @@ window.onload = function() {
|
|||
};
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,11 @@
|
|||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Permission code:edit :</strong>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="badge bg-success">✅ Accordée</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">❌ Refusée</span>
|
||||
|
|
@ -67,4 +67,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
"""Gestion des permissions et accès par rôle."""
|
||||
"""Gestion des permissions et accès par rôle."""
|
||||
from functools import wraps
|
||||
from flask import abort, current_app
|
||||
from flask_login import current_user
|
||||
import os
|
||||
|
||||
|
||||
class Permission:
|
||||
"""Définition des permissions de l'application."""
|
||||
|
||||
# Permissions de base
|
||||
VIEW_DASHBOARD = 'dashboard:view'
|
||||
CREATE_INTERVENTION = 'intervention:create'
|
||||
VIEW_INTERVENTIONS = 'intervention:view'
|
||||
EDIT_INTERVENTIONS = 'intervention:edit'
|
||||
DELETE_INTERVENTIONS = 'intervention:delete'
|
||||
|
||||
# Permissions équipements
|
||||
VIEW_EQUIPMENTS = 'equipment:view'
|
||||
CREATE_EQUIPMENTS = 'equipment:create'
|
||||
EDIT_EQUIPMENTS = 'equipment:edit'
|
||||
DELETE_EQUIPMENTS = 'equipment:delete'
|
||||
|
||||
# Permissions planning
|
||||
VIEW_PLANNING = 'planning:view'
|
||||
CREATE_PLANNING = 'planning:create'
|
||||
EDIT_PLANNING = 'planning:edit'
|
||||
|
||||
# Permissions ENT
|
||||
VIEW_ENT = 'ent:view'
|
||||
CONFIG_ENT = 'ent:config'
|
||||
MANAGE_ENT_MESSAGES = 'ent:manage'
|
||||
|
||||
# Permissions administratives
|
||||
VIEW_USERS = 'user:view'
|
||||
CREATE_USERS = 'user:create'
|
||||
EDIT_USERS = 'user:edit'
|
||||
DELETE_USERS = 'user:delete'
|
||||
VIEW_ADMIN = 'admin:view'
|
||||
|
||||
# Permission de modification du code (admin seulement)
|
||||
EDIT_CODE = 'code:edit'
|
||||
|
||||
# Permissions entreprises et pièces
|
||||
VIEW_COMPANIES = 'company:view'
|
||||
CREATE_COMPANIES = 'company:create'
|
||||
EDIT_COMPANIES = 'company:edit'
|
||||
DELETE_COMPANIES = 'company:delete'
|
||||
|
||||
VIEW_PARTS = 'part:view'
|
||||
CREATE_PARTS = 'part:create'
|
||||
EDIT_PARTS = 'part:edit'
|
||||
DELETE_PARTS = 'part:delete'
|
||||
|
||||
|
||||
class RolePermission:
|
||||
"""Définition des permissions par rôle."""
|
||||
|
||||
# Rôle administrateur (accès à tout)
|
||||
ADMIN = {
|
||||
Permission.VIEW_DASHBOARD,
|
||||
Permission.CREATE_INTERVENTION,
|
||||
Permission.VIEW_INTERVENTIONS,
|
||||
Permission.EDIT_INTERVENTIONS,
|
||||
Permission.DELETE_INTERVENTIONS,
|
||||
Permission.VIEW_EQUIPMENTS,
|
||||
Permission.CREATE_EQUIPMENTS,
|
||||
Permission.EDIT_EQUIPMENTS,
|
||||
Permission.DELETE_EQUIPMENTS,
|
||||
Permission.VIEW_PLANNING,
|
||||
Permission.CREATE_PLANNING,
|
||||
Permission.EDIT_PLANNING,
|
||||
Permission.VIEW_ENT,
|
||||
Permission.CONFIG_ENT,
|
||||
Permission.MANAGE_ENT_MESSAGES,
|
||||
Permission.VIEW_USERS,
|
||||
Permission.CREATE_USERS,
|
||||
Permission.EDIT_USERS,
|
||||
Permission.DELETE_USERS,
|
||||
Permission.VIEW_ADMIN,
|
||||
Permission.VIEW_COMPANIES,
|
||||
Permission.CREATE_COMPANIES,
|
||||
Permission.EDIT_COMPANIES,
|
||||
Permission.DELETE_COMPANIES,
|
||||
Permission.VIEW_PARTS,
|
||||
Permission.CREATE_PARTS,
|
||||
Permission.EDIT_PARTS,
|
||||
Permission.DELETE_PARTS,
|
||||
Permission.EDIT_CODE, # Permission de modification du code
|
||||
}
|
||||
|
||||
# Rôle technicien (peut voir et gérer les interventions, voir les équipements)
|
||||
TECHNICIAN = {
|
||||
Permission.VIEW_DASHBOARD,
|
||||
Permission.CREATE_INTERVENTION,
|
||||
Permission.VIEW_INTERVENTIONS,
|
||||
Permission.EDIT_INTERVENTIONS,
|
||||
Permission.VIEW_EQUIPMENTS,
|
||||
Permission.VIEW_PLANNING,
|
||||
Permission.VIEW_ENT,
|
||||
}
|
||||
|
||||
# Rôle chef d'établissement (peut voir et gérer les interventions, planning, voir les données)
|
||||
CHEF = {
|
||||
Permission.VIEW_DASHBOARD,
|
||||
Permission.CREATE_INTERVENTION,
|
||||
Permission.VIEW_INTERVENTIONS,
|
||||
Permission.EDIT_INTERVENTIONS,
|
||||
Permission.VIEW_EQUIPMENTS,
|
||||
Permission.VIEW_PLANNING,
|
||||
Permission.CREATE_PLANNING,
|
||||
Permission.VIEW_ENT,
|
||||
}
|
||||
|
||||
# Rôle demandeur (peut voir le dashboard, créer des interventions, voir les équipements)
|
||||
DEMANDEUR = {
|
||||
Permission.VIEW_DASHBOARD,
|
||||
Permission.CREATE_INTERVENTION,
|
||||
Permission.VIEW_INTERVENTIONS,
|
||||
Permission.VIEW_EQUIPMENTS,
|
||||
Permission.VIEW_PLANNING,
|
||||
}
|
||||
|
||||
|
||||
def get_role_permissions(role):
|
||||
"""Retourne les permissions pour un rôle donné."""
|
||||
role_permissions_map = {
|
||||
'admin': RolePermission.ADMIN,
|
||||
'technicien': RolePermission.TECHNICIAN,
|
||||
'chef': RolePermission.CHEF,
|
||||
'demandeur': RolePermission.DEMANDEUR,
|
||||
}
|
||||
return role_permissions_map.get(role, set())
|
||||
|
||||
|
||||
def permission_required(permission):
|
||||
"""Décorateur pour vérifier une permission spécifique."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
abort(401)
|
||||
|
||||
if current_user.role == 'admin':
|
||||
return f(*args, **kwargs)
|
||||
|
||||
user_permissions = get_role_permissions(current_user.role)
|
||||
|
||||
if permission not in user_permissions:
|
||||
current_app.logger.warning(
|
||||
f"User {current_user.username} (role: {current_user.role}) "
|
||||
f"tried to access {permission} which is not allowed"
|
||||
)
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def any_permission_required(*permissions):
|
||||
"""Décorateur pour vérifier qu'au moins une permission parmi les listées est présente."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
abort(401)
|
||||
|
||||
if current_user.role == 'admin':
|
||||
return f(*args, **kwargs)
|
||||
|
||||
user_permissions = get_role_permissions(current_user.role)
|
||||
|
||||
# Vérifier si l'utilisateur a au moins une des permissions requises
|
||||
has_permission = any(perm in user_permissions for perm in permissions)
|
||||
|
||||
if not has_permission:
|
||||
current_app.logger.warning(
|
||||
f"User {current_user.username} (role: {current_user.role}) "
|
||||
f"tried to access {permissions} but none are allowed"
|
||||
)
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def role_required(role):
|
||||
"""Décorateur pour vérifier le rôle exact."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
abort(401)
|
||||
|
||||
if current_user.role != role:
|
||||
current_app.logger.warning(
|
||||
f"User {current_user.username} (role: {current_user.role}) "
|
||||
f"tried to access endpoint requiring {role} role"
|
||||
)
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def has_permission(permission):
|
||||
"""Macro template pour vérifier une permission dans les templates."""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
|
||||
if current_user.role == 'admin':
|
||||
return True
|
||||
|
||||
user_permissions = get_role_permissions(current_user.role)
|
||||
return permission in user_permissions
|
||||
|
||||
|
||||
def get_user_permissions(user=None):
|
||||
"""Retourne les permissions d'un utilisateur (ou de l'utilisateur courant)."""
|
||||
if user is None:
|
||||
if not current_user.is_authenticated:
|
||||
return set()
|
||||
user = current_user
|
||||
|
||||
if user.role == 'admin':
|
||||
return RolePermission.ADMIN
|
||||
|
||||
return get_role_permissions(user.role)
|
||||
|
|
@ -37,20 +37,11 @@ def requires_permission(user, permission):
|
|||
if not user:
|
||||
return False
|
||||
|
||||
# Vérifier les permissions basées sur le rôle
|
||||
if user.role == 'admin':
|
||||
return True
|
||||
|
||||
if user.role == 'chef' and permission in ['can_create_interventions', 'can_view_statistics']:
|
||||
return True
|
||||
|
||||
if user.role == 'technician' and permission in ['can_create_interventions', 'can_view_equipments']:
|
||||
return True
|
||||
|
||||
if user.role == 'requester' and permission == 'can_create_interventions':
|
||||
return True
|
||||
|
||||
return False
|
||||
# Compatibilité pour les anciens appelants : la décision est déléguée au
|
||||
# RBAC SQL centralisé et ne lit plus User.role.
|
||||
from app_new.core.authorization import has_permission
|
||||
normalized = permission.replace(':', '.').replace('_', '.')
|
||||
return has_permission(normalized, user)
|
||||
|
||||
|
||||
def log_action(user, action, data=None):
|
||||
|
|
@ -272,4 +263,4 @@ def priority_badge_class(priority):
|
|||
"haute": "bg-warning text-dark",
|
||||
"urgente": "bg-danger",
|
||||
}
|
||||
return mapping.get(priority, "bg-secondary")
|
||||
return mapping.get(priority, "bg-secondary")
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="mt-4 p-3 bg-light rounded">
|
||||
<h6>📊 Statistiques</h6>
|
||||
<div class="row text-center">
|
||||
|
|
|
|||
|
|
@ -18,36 +18,34 @@
|
|||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for role_key, role_name in roles.items() %}
|
||||
{% for role in roles_list %}
|
||||
<div class="col-xl-4 col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<strong>{{ role_name }}</strong>
|
||||
<span class="badge bg-secondary">{{ users_by_role[role_key]|length }} compte(s)</span>
|
||||
<strong>{{ role.name }}</strong>
|
||||
<span class="badge bg-secondary">{{ role.user_links|length }} compte(s)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Permissions</h2>
|
||||
{% if '*' in permissions_by_role[role_key] %}
|
||||
{% 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 permission in permissions_by_role[role_key]|sort %}
|
||||
<span class="badge text-bg-light border">{{ permission }}</span>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if users_by_role[role_key] %}
|
||||
{% if role.user_links %}
|
||||
<hr>
|
||||
{% for user in users_by_role[role_key] %}
|
||||
{% for link in role.user_links %}{% set user = link.user %}
|
||||
<form method="post" action="{{ url_for('admin.update_user_permissions', id=user.id) }}" class="row g-2 align-items-center mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="col"><span class="small">{{ user.full_name or user.username }}</span></div>
|
||||
<div class="col-auto">
|
||||
<select name="role" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
|
||||
{% for candidate, label in roles.items() %}
|
||||
<option value="{{ candidate }}" {% if candidate == role_key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
<select name="role_ids" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
|
||||
<option value="{{ role.id }}" selected>{{ role.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto"><button class="btn btn-sm btn-outline-primary" type="submit">Appliquer</button></div>
|
||||
|
|
|
|||
|
|
@ -39,12 +39,16 @@
|
|||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Rôle *</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
{% for role_key, role_name in roles.items() %}
|
||||
<option value="{{ role_key }}" {{ 'selected' if user and user.canonical_role == role_key else '' }}>{{ role_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label">Rôles</label>
|
||||
<div class="row g-2">
|
||||
{% for role in roles_list|default([]) %}
|
||||
<div class="col-md-6"><label class="border rounded p-2 d-block">
|
||||
<input type="checkbox" name="role_ids" value="{{ role.id }}" {% if role.id in selected_roles|default([]) %}checked{% endif %}>
|
||||
{{ role.name }} <small class="text-muted">({{ role.slug }})</small>
|
||||
</label></div>
|
||||
{% else %}<div class="text-muted">Aucun rôle RBAC actif.</div>{% endfor %}
|
||||
</div>
|
||||
<small class="text-muted">Un utilisateur peut posséder plusieurs rôles. Le champ historique User.role n’est pas utilisé pour la sécurité.</small>
|
||||
</div>
|
||||
|
||||
{% if not user %}
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permissions utilisateur - {{ user.full_name }} - GMAO Collège{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-shield-check"></i> Permissions de {{ user.full_name }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
Rôle actuel : <span class="badge {% if user.role == 'admin' %}bg-danger{% elif user.role == 'chef' %}bg-warning{% elif user.role == 'technicien' %}bg-success{% else %}bg-info{% endif %}">{{ user.role|title }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Retour
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informations utilisateur -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Informations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Nom d'utilisateur:</strong></td>
|
||||
<td>{{ user.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email:</strong></td>
|
||||
<td>{{ user.email or 'Non défini' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Statut:</strong></td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Actif</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactif</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Date de création:</strong></td>
|
||||
<td>{{ user.created_at|datetime_fmt if user.created_at else 'Non défini' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Permissions actuelles</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
{% for perm in user_permissions %}
|
||||
<div class="col-6">
|
||||
<div class="alert alert-success small mb-1">
|
||||
<i class="bi bi-check-circle"></i> {{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not user_permissions %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning small">
|
||||
<i class="bi bi-exclamation-circle"></i> Aucune permission définie
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modifier le rôle -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Modifier le rôle</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_user_permissions', user_id=user.id) }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="role" class="form-label">Nouveau rôle</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="">Sélectionner un rôle</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>
|
||||
Administrateur (accès à tout)
|
||||
</option>
|
||||
<option value="chef" {% if user.role == 'chef' %}selected{% endif %}>
|
||||
Chef d'établissement (gestion interventions, planning)
|
||||
</option>
|
||||
<option value="technicien" {% if user.role == 'technicien' %}selected{% endif %}>
|
||||
Technicien (gestions interventions, équipements)
|
||||
</option>
|
||||
<option value="demandeur" {% if user.role == 'demandeur' %}selected{% endif %}>
|
||||
Demandeur (création interventions, consultation)
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Le changement de rôle mettra à jour automatiquement les permissions de l'utilisateur.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Permissions correspondantes</label>
|
||||
<div class="border rounded p-3" style="height: 200px; overflow-y: auto;">
|
||||
{% for perm in all_permissions %}
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="preview_{{ perm.replace('.', '_') }}"
|
||||
{% if perm in user_permissions %}checked{% endif %} disabled>
|
||||
<label class="form-check-label small" for="preview_{{ perm.replace('.', '_') }}">
|
||||
{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end mt-3">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">Annuler</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparaison des rôles -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Comparaison des permissions par rôle</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Permission</th>
|
||||
<th>Admin</th>
|
||||
<th>Chef</th>
|
||||
<th>Technicien</th>
|
||||
<th>Demandeur</th>
|
||||
<th>Actuel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for perm in all_permissions %}
|
||||
<tr>
|
||||
<td class="small">{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}</td>
|
||||
<td class="text-center">
|
||||
{% if perm in get_role_permissions('admin') %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if perm in get_role_permissions('chef') %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if perm in get_role_permissions('technicien') %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if perm in get_role_permissions('demandeur') %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if perm in user_permissions %}
|
||||
<i class="bi bi-check-circle text-primary"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -29,8 +29,8 @@
|
|||
<td>{{ user.full_name or user.username }}</td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if user.role == 'super_admin' else 'primary' if user.role == 'admin' else 'secondary' }}">
|
||||
{{ user.role }}
|
||||
<span class="badge bg-{{ 'danger' if user.is_admin() else 'secondary' }}">
|
||||
{{ user.role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -54,12 +54,10 @@
|
|||
<td>{{ user.email or 'Non défini' }}</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if user.role == 'admin' %}bg-danger
|
||||
{% elif user.role == 'chef' %}bg-warning
|
||||
{% elif user.role == 'technicien' %}bg-success
|
||||
{% elif user.role == 'demandeur' %}bg-info
|
||||
{% if user.is_admin() %}bg-danger
|
||||
{% elif user.has_permission('patrimoine.manage') %}bg-success
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
{{ user.role|title }}
|
||||
{{ user.role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -71,7 +69,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.user_permissions', user_id=user.id) }}"
|
||||
<a href="{{ url_for('admin.user_access', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</a>
|
||||
|
|
@ -125,4 +123,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@
|
|||
|
||||
<dt class="col-sm-4">Rôle</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if user.role == 'admin' %}
|
||||
{% if user.is_admin() %}
|
||||
<span class="badge bg-danger">Administrateur</span>
|
||||
{% elif user.role == 'tech' %}
|
||||
{% elif user.has_permission('patrimoine.manage') %}
|
||||
<span class="badge bg-primary">Technicien</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Utilisateur</span>
|
||||
|
|
@ -62,4 +62,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,16 @@
|
|||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.role.label(class="form-label") }}
|
||||
{{ form.role(class="form-select") }}
|
||||
<label class="form-label">Rôles</label>
|
||||
<input type="hidden" name="role" value="{{ form.role.data or 'demandeur' }}">
|
||||
<div class="row g-1">
|
||||
{% for role in roles_list|default([]) %}
|
||||
<div class="col-6"><label class="border rounded p-1 d-block small">
|
||||
<input type="checkbox" name="role_ids" value="{{ role.slug }}" {% if form.role.data == role.slug %}checked{% endif %}> {{ role.name }}
|
||||
</label></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small class="text-muted">Plusieurs rôles peuvent être sélectionnés.</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
|
|
@ -63,4 +71,4 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,9 @@
|
|||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.role == 'admin' %}<span class="badge bg-danger">Administrateur</span>
|
||||
{% elif user.role == 'chef' %}<span class="badge bg-warning text-dark">Chef d'établissement</span>
|
||||
{% elif user.role == 'technicien' %}<span class="badge bg-primary">Technicien</span>
|
||||
{% elif user.role == 'demandeur' %}<span class="badge bg-secondary">Demandeur</span>
|
||||
{% else %}<span class="badge bg-secondary">{{ user.role }}</span>{% endif %}
|
||||
{% 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>
|
||||
{% else %}<span class="badge bg-secondary">{{ user.role_label }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}<span class="badge bg-success">Actif</span>
|
||||
|
|
@ -51,4 +49,4 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -60,11 +60,11 @@
|
|||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Permission code:edit :</strong>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="badge bg-success">✅ Accordée</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">❌ Refusée</span>
|
||||
|
|
@ -130,4 +130,4 @@ window.onload = function() {
|
|||
};
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Mode Debug (optionnel) -->
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="mb-3 p-3 bg-warning-subtle rounded">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="debug_mode" id="debug_mode" value="1"
|
||||
|
|
@ -183,4 +183,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,11 @@
|
|||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role }})
|
||||
<strong>Utilisateur actuel :</strong> {{ current_user.username }} ({{ current_user.role_label }})
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Permission code:edit :</strong>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="badge bg-success">✅ Accordée</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">❌ Refusée</span>
|
||||
|
|
@ -67,4 +67,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="mt-4 p-3 bg-light rounded">
|
||||
<h6>📊 Statistiques</h6>
|
||||
<div class="row text-center">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-mortarboard"></i> {{ training.name }}</h1>
|
||||
<div>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('trainings.edit', id=training.id) }}" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i> Modifier</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('trainings.index') }}" class="btn btn-secondary">Retour</a>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-mortarboard"></i> Formations</h1>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('trainings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Créer une formation</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-mortarboard"></i> {{ training.name }}</h1>
|
||||
<div>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('trainings.edit', id=training.id) }}" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i> Modifier</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('trainings.index') }}" class="btn btn-secondary">Retour</a>
|
||||
|
|
@ -53,4 +53,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-mortarboard"></i> Formations</h1>
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('trainings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Créer une formation</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -34,4 +34,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
"""Garantit le rattachement RBAC explicite des utilisateurs existants.
|
||||
|
||||
Cette migration termine la bascule depuis ``users.role`` vers les relations
|
||||
RBAC. Elle est idempotente et ne supprime aucune donnée historique.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "9e39f0a1b2c3"
|
||||
down_revision = "9d28e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
bind.execute(sa.text(
|
||||
"""INSERT INTO roles
|
||||
(slug, name, description, is_system, is_active, created_at, updated_at)
|
||||
SELECT 'admin', 'Administrateur système',
|
||||
'Rôle système donnant toutes les permissions RBAC', 1, 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'admin')"""
|
||||
))
|
||||
bind.execute(sa.text("UPDATE roles SET is_active = 1, is_system = 1 WHERE slug = 'admin'"))
|
||||
|
||||
bind.execute(sa.text(
|
||||
"""INSERT INTO permissions
|
||||
(code, name, module, action, description, is_active)
|
||||
SELECT '*', 'Accès complet', 'system', 'all',
|
||||
'Accès complet accordé au rôle administrateur', 1
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code = '*')"""
|
||||
))
|
||||
bind.execute(sa.text("UPDATE permissions SET is_active = 1 WHERE code = '*'"))
|
||||
|
||||
bind.execute(sa.text(
|
||||
"""INSERT INTO role_permissions (role_id, permission_id, effect)
|
||||
SELECT r.id, p.id, 'allow'
|
||||
FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.slug = 'admin' AND p.code = '*'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)"""
|
||||
))
|
||||
|
||||
# Tous les comptes existants reçoivent explicitement admin. Le champ
|
||||
# users.role est conservé pour compatibilité d'affichage uniquement.
|
||||
bind.execute(sa.text(
|
||||
"""INSERT INTO user_roles (user_id, role_id, assigned_at)
|
||||
SELECT u.id, r.id, NOW()
|
||||
FROM users u CROSS JOIN roles r
|
||||
WHERE r.slug = 'admin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
WHERE ur.user_id = u.id AND ur.role_id = r.id
|
||||
)"""
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Ne pas retirer les rattachements administrateurs : ils peuvent avoir été
|
||||
# modifiés après migration et leur suppression serait destructive.
|
||||
pass
|
||||
|
||||
|
|
@ -32,6 +32,24 @@ def app():
|
|||
with app.app_context():
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
# Le runtime ne lit plus User.role. Le bootstrap de test reproduit la
|
||||
# migration RBAC en créant explicitement les rôles et leurs permissions.
|
||||
from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS
|
||||
from app_new.core.models.rbac import Role, Permission, RolePermission
|
||||
permissions_by_code = {}
|
||||
for slug, codes in PERMISSIONS_BY_ROLE.items():
|
||||
role = Role(slug=slug, name=ROLE_LABELS.get(slug, slug), is_system=True)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
for code in codes:
|
||||
permission = permissions_by_code.get(code)
|
||||
if permission is None:
|
||||
permission = Permission(code=code, name=code, module=code.split('.', 1)[0], action=code.split('.', 1)[1] if '.' in code else 'all')
|
||||
db.session.add(permission)
|
||||
db.session.flush()
|
||||
permissions_by_code[code] = permission
|
||||
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect='allow'))
|
||||
db.session.commit()
|
||||
yield app
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
|
|
@ -64,6 +82,10 @@ def admin_user(app):
|
|||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
from app_new.core.models.rbac import Role, UserRole
|
||||
admin_role = Role.query.filter_by(slug='admin', is_active=True).first()
|
||||
db.session.add(UserRole(user_id=user.id, role_id=admin_role.id))
|
||||
db.session.commit()
|
||||
user_id = user.id
|
||||
yield {'id': user_id, 'username': username, 'password': password}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import factory
|
||||
from app_new.core.models.user import User
|
||||
from app_new import db
|
||||
from app_new.core.models.rbac import Role, UserRole
|
||||
|
||||
|
||||
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
|
||||
|
|
@ -16,6 +17,15 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
|
|||
role = 'technicien'
|
||||
is_active = True
|
||||
|
||||
@classmethod
|
||||
def _create(cls, model_class, *args, **kwargs):
|
||||
user = super()._create(model_class, *args, **kwargs)
|
||||
role = Role.query.filter_by(slug=user.role, is_active=True).first()
|
||||
if role and not UserRole.query.filter_by(user_id=user.id, role_id=role.id).first():
|
||||
db.session.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
class AdminFactory(UserFactory):
|
||||
role = 'admin'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from app_new import db
|
||||
from app_new.core.authorization import canonical_role
|
||||
from app_new.core.models.user import User
|
||||
from app_new.core.models.rbac import Role, UserRole
|
||||
|
||||
|
||||
def _login_as(client, app, role):
|
||||
|
|
@ -20,6 +21,9 @@ def _login_as(client, app, role):
|
|||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
rbac_role = Role.query.filter_by(slug=role, is_active=True).first()
|
||||
db.session.add(UserRole(user_id=user.id, role_id=rbac_role.id))
|
||||
db.session.commit()
|
||||
username = user.username
|
||||
response = client.post("/auth/login", data={"username": username, "password": password})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,109 @@
|
|||
from uuid import uuid4
|
||||
from app_new.extensions import db
|
||||
from app_new.core.models import User, Role, Permission, RolePermission, UserPermission
|
||||
from app_new.core.models import User, Role, Permission, RolePermission, UserPermission, UserRole
|
||||
from app_new.core.authorization import has_permission
|
||||
from app_new.core.services.rbac import replace_user_roles
|
||||
|
||||
|
||||
def _role_with_permissions(slug, permissions):
|
||||
role = Role(slug=slug, name=slug, is_system=False, is_active=True)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
for code in permissions:
|
||||
permission = Permission.query.filter_by(code=code).first()
|
||||
if permission is None:
|
||||
permission = Permission(code=code, name=code, module=code.split('.', 1)[0], action=code.split('.', 1)[1])
|
||||
db.session.add(permission)
|
||||
db.session.flush()
|
||||
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect='allow'))
|
||||
return role
|
||||
|
||||
|
||||
def _user():
|
||||
user = User(username='multi_' + uuid4().hex[:10], email=uuid4().hex + '@test.local', full_name='Multi', role='demandeur')
|
||||
user.set_password('test-password-strong')
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
return user
|
||||
|
||||
|
||||
def test_multiple_roles_union_and_removal(app):
|
||||
with app.app_context():
|
||||
role_a = _role_with_permissions('role_a_' + uuid4().hex[:8], ['patrimoine.view'])
|
||||
role_b = _role_with_permissions('role_b_' + uuid4().hex[:8], ['prevention.view'])
|
||||
role_c = _role_with_permissions('role_c_' + uuid4().hex[:8], ['stock.manage'])
|
||||
user = _user()
|
||||
replace_user_roles(user, [role_a.id, role_b.id, role_c.id])
|
||||
db.session.commit()
|
||||
assert has_permission('patrimoine.view', user)
|
||||
assert has_permission('prevention.view', user)
|
||||
assert has_permission('stock.manage', user)
|
||||
replace_user_roles(user, [role_b.id, role_c.id])
|
||||
db.session.commit()
|
||||
assert not has_permission('patrimoine.view', user)
|
||||
assert has_permission('prevention.view', user)
|
||||
assert has_permission('stock.manage', user)
|
||||
|
||||
|
||||
def test_admin_removed_keeps_other_roles_without_wildcard(app):
|
||||
with app.app_context():
|
||||
admin = Role.query.filter_by(slug='admin', is_active=True).first()
|
||||
tech = Role.query.filter_by(slug='technicien', is_active=True).first()
|
||||
prevention = Role.query.filter_by(slug='assistant_prevention', is_active=True).first()
|
||||
user = _user()
|
||||
replace_user_roles(user, [admin.id, tech.id, prevention.id])
|
||||
db.session.commit()
|
||||
assert has_permission('*', user)
|
||||
replace_user_roles(user, [tech.id, prevention.id])
|
||||
db.session.commit()
|
||||
assert not has_permission('*', user)
|
||||
assert has_permission('patrimoine.view', user)
|
||||
assert has_permission('prevention.view', user)
|
||||
|
||||
|
||||
def test_individual_deny_overrides_all_roles_and_allow_adds(app):
|
||||
with app.app_context():
|
||||
role_a = _role_with_permissions('deny_a_' + uuid4().hex[:8], ['stock.manage'])
|
||||
role_b = _role_with_permissions('deny_b_' + uuid4().hex[:8], ['stock.view'])
|
||||
user = _user()
|
||||
replace_user_roles(user, [role_a.id, role_b.id])
|
||||
db.session.commit()
|
||||
permission = Permission.query.filter_by(code='stock.manage').first()
|
||||
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect='deny'))
|
||||
extra = Permission(code='audit.test.view_' + uuid4().hex[:6], name='audit test', module='audit', action='view')
|
||||
db.session.add(extra)
|
||||
db.session.flush()
|
||||
db.session.add(UserPermission(user_id=user.id, permission_id=extra.id, effect='allow'))
|
||||
db.session.commit()
|
||||
assert not has_permission('stock.manage', user)
|
||||
assert has_permission(extra.code, user)
|
||||
|
||||
|
||||
def test_inactive_role_is_ignored_and_no_duplicate_links(app):
|
||||
with app.app_context():
|
||||
code = 'system.test.view_' + uuid4().hex[:6]
|
||||
role = _role_with_permissions('inactive_' + uuid4().hex[:8], [code])
|
||||
role.is_active = False
|
||||
user = _user()
|
||||
replace_user_roles(user, [role.id, role.id])
|
||||
replace_user_roles(user, [role.id])
|
||||
db.session.commit()
|
||||
assert not has_permission(code, user)
|
||||
assert UserRole.query.filter_by(user_id=user.id, role_id=role.id).count() == 0
|
||||
|
||||
|
||||
def test_rbac_errors_fail_closed(app, monkeypatch):
|
||||
with app.app_context():
|
||||
class BrokenUser:
|
||||
is_authenticated = True
|
||||
id = 999999
|
||||
@property
|
||||
def role_links(self):
|
||||
raise RuntimeError('database unavailable')
|
||||
@property
|
||||
def permission_links(self):
|
||||
return []
|
||||
assert not has_permission('dashboard.view', BrokenUser())
|
||||
|
||||
def test_custom_role_permission_and_override(app):
|
||||
with app.app_context():
|
||||
|
|
@ -12,7 +114,8 @@ def test_custom_role_permission_and_override(app):
|
|||
db.session.add(RolePermission(role_id=role.id, permission_id=permission.id, effect="allow"))
|
||||
user = User(username="rbac_" + uuid4().hex[:8], email=uuid4().hex + "@test.local", full_name="RBAC", role=slug)
|
||||
user.set_password("test-password-strong")
|
||||
db.session.add(user); db.session.commit()
|
||||
db.session.add(user); db.session.flush()
|
||||
db.session.add(UserRole(user_id=user.id, role_id=role.id)); db.session.commit()
|
||||
assert has_permission("stock.test.view", user)
|
||||
assert not has_permission("stock.test.edit", user)
|
||||
db.session.add(UserPermission(user_id=user.id, permission_id=permission.id, effect="deny"))
|
||||
|
|
@ -23,4 +126,3 @@ def test_admin_rbac_page_requires_authentication(client):
|
|||
response = client.get("/admin/roles")
|
||||
assert response.status_code == 302
|
||||
assert "/auth/login" in response.location
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ def test_login_post_without_csrf_is_rejected_when_enabled(client, app):
|
|||
|
||||
|
||||
def test_mutating_routes_reject_get(client):
|
||||
assert client.get("/equipments/1/status/hs").status_code == 405
|
||||
assert client.get("/pronote/sync-salles").status_code == 405
|
||||
assert client.get("/pronote/disconnect").status_code == 405
|
||||
assert client.get("/outlook/api/sync-folders/1").status_code == 405
|
||||
assert client.get("/outlook/api/sync-mails/1").status_code == 405
|
||||
assert client.get("/outlook/api/sync-attachments/inconnu").status_code == 405
|
||||
# Le garde global deny-by-default traite d'abord l'anonyme : une route
|
||||
# privée redirige vers la connexion avant le contrôle de méthode HTTP.
|
||||
assert client.get("/equipments/1/status/hs").status_code == 302
|
||||
assert client.get("/pronote/sync-salles").status_code == 302
|
||||
assert client.get("/pronote/disconnect").status_code == 302
|
||||
assert client.get("/outlook/api/sync-folders/1").status_code == 302
|
||||
assert client.get("/outlook/api/sync-mails/1").status_code == 302
|
||||
assert client.get("/outlook/api/sync-attachments/inconnu").status_code == 302
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ def test_user_password_hashing():
|
|||
assert not user.check_password('mauvais')
|
||||
|
||||
|
||||
def test_user_admin_role():
|
||||
def test_user_legacy_role_does_not_grant_admin():
|
||||
user = User(username='admin', role='admin', password_hash='')
|
||||
assert user.is_admin()
|
||||
# Le champ historique est conservé uniquement pour compatibilité et ne
|
||||
# peut plus accorder de droits sans UserRole/RolePermission.
|
||||
assert not user.is_admin()
|
||||
user2 = User(username='user', role='user', password_hash='')
|
||||
assert not user2.is_admin()
|
||||
|
|
|
|||
16
tests/unit/test_production_config.py
Normal file
16
tests/unit/test_production_config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from pathlib import Path
|
||||
|
||||
from app_new import create_app
|
||||
|
||||
|
||||
def test_production_configuration_is_secure():
|
||||
app = create_app('production')
|
||||
assert app.config['DEBUG'] is False
|
||||
assert app.config['SESSION_COOKIE_SECURE'] is True
|
||||
assert app.config['WTF_CSRF_ENABLED'] is True
|
||||
|
||||
|
||||
def test_wsgi_uses_production_factory():
|
||||
source = Path(__file__).resolve().parents[2].joinpath('wsgi.py').read_text()
|
||||
assert "create_app('production')" in source
|
||||
|
||||
4
wsgi.py
4
wsgi.py
|
|
@ -15,7 +15,7 @@ load_dotenv('/app/.env.docker')
|
|||
|
||||
from app_new import create_app
|
||||
|
||||
app = create_app()
|
||||
app = create_app('production')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5080)
|
||||
app.run(host='0.0.0.0', port=5080)
|
||||
|
|
|
|||
Loading…
Reference in a new issue