transfer from private server/git server
This commit is contained in:
1
web/services/__init__.py
Normal file
1
web/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Couche services/ — logique métier. Les routes n'appellent jamais les models/ directement."""
|
||||
55
web/services/annonce_service.py
Normal file
55
web/services/annonce_service.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
annonce_service.py — Logique métier du module Annonces (EXF-01 à EXF-04 du CDC).
|
||||
|
||||
Validation des champs AVANT d'appeler la couche models/, et journalisation
|
||||
systématique des actions sensibles dans audit_log (Bloc 1 - C10).
|
||||
"""
|
||||
|
||||
from models import annonce_model
|
||||
from services import audit_service
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Levée quand les données fournies par l'utilisateur sont invalides."""
|
||||
|
||||
|
||||
TITRE_MAX_LEN = 150
|
||||
|
||||
|
||||
def _validate(titre, contenu):
|
||||
if not titre or not titre.strip():
|
||||
raise ValidationError("Le titre est obligatoire.")
|
||||
if len(titre) > TITRE_MAX_LEN:
|
||||
raise ValidationError(f"Le titre dépasse {TITRE_MAX_LEN} caractères.")
|
||||
if not contenu or not contenu.strip():
|
||||
raise ValidationError("Le contenu est obligatoire.")
|
||||
|
||||
|
||||
def list_annonces(db):
|
||||
return annonce_model.fetch_all(db)
|
||||
|
||||
|
||||
def create_annonce(db, titre, contenu, auteur_sub, epinglee=False, ip=None):
|
||||
_validate(titre, contenu)
|
||||
new_id = annonce_model.insert(db, titre.strip(), contenu.strip(), auteur_sub, epinglee)
|
||||
audit_service.log(db, "CREATE_ANNONCE", f"annonce:{new_id}", "SUCCESS", auteur_sub, ip)
|
||||
return new_id
|
||||
|
||||
|
||||
def update_annonce(db, annonce_id, titre, contenu, auteur_sub, epinglee=False, ip=None):
|
||||
_validate(titre, contenu)
|
||||
existing = annonce_model.get(db, annonce_id)
|
||||
if not existing:
|
||||
raise annonce_model.AnnonceNotFoundError(f"Annonce {annonce_id} introuvable")
|
||||
rows = annonce_model.update(db, annonce_id, titre.strip(), contenu.strip(), epinglee)
|
||||
audit_service.log(db, "UPDATE_ANNONCE", f"annonce:{annonce_id}", "SUCCESS", auteur_sub, ip)
|
||||
return rows
|
||||
|
||||
|
||||
def delete_annonce(db, annonce_id, auteur_sub, ip=None):
|
||||
existing = annonce_model.get(db, annonce_id)
|
||||
if not existing:
|
||||
raise annonce_model.AnnonceNotFoundError(f"Annonce {annonce_id} introuvable")
|
||||
rows = annonce_model.delete(db, annonce_id)
|
||||
audit_service.log(db, "DELETE_ANNONCE", f"annonce:{annonce_id}", "SUCCESS", auteur_sub, ip)
|
||||
return rows
|
||||
11
web/services/audit_service.py
Normal file
11
web/services/audit_service.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""audit_service.py — Point d'entrée unique pour écrire dans audit_log."""
|
||||
|
||||
from models import audit_model
|
||||
|
||||
|
||||
def log(db, action, ressource, statut, utilisateur_sub=None, ip_address=None):
|
||||
return audit_model.insert(db, action, statut, ressource, utilisateur_sub, ip_address)
|
||||
|
||||
|
||||
def recent(db, limit=100):
|
||||
return audit_model.fetch_recent(db, limit)
|
||||
83
web/services/auth_service.py
Normal file
83
web/services/auth_service.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
auth_service.py — Fonctions de validation des droits d'accès, centralisées et
|
||||
réutilisées par toutes les routes (Bloc 1 - C9 : taux de réutilisation > 80 %
|
||||
via services/ ; Bloc 1 - C8 : "vérification JWT à chaque requête").
|
||||
|
||||
Toute route sensible doit passer par current_user() / require_login() /
|
||||
require_admin() plutôt que de relire directement flask.session, afin que la
|
||||
règle d'autorisation soit définie à un seul endroit.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import session, jsonify, request
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def current_user():
|
||||
"""Retourne l'utilisateur en session (dict issu du token OIDC) ou None."""
|
||||
return session.get("user")
|
||||
|
||||
|
||||
def is_authenticated():
|
||||
return current_user() is not None
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def is_admin(user=_UNSET):
|
||||
"""True si l'utilisateur appartient au groupe Keycloak /admin.
|
||||
|
||||
Appelé sans argument, lit l'utilisateur courant dans la session Flask
|
||||
(nécessite un contexte de requête actif). Appelé avec un argument
|
||||
explicite (y compris None), ce dict/None est utilisé tel quel — utile
|
||||
pour tester la fonction hors contexte de requête (cf. UT-11).
|
||||
"""
|
||||
if user is _UNSET:
|
||||
user = current_user()
|
||||
if not user:
|
||||
return False
|
||||
return Config.ADMIN_GROUP in user.get("groups", [])
|
||||
|
||||
|
||||
def require_login(view_func):
|
||||
"""Décorateur : bloque l'accès si aucune session utilisateur valide."""
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_authenticated():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_admin(view_func):
|
||||
"""Décorateur : bloque l'accès si l'utilisateur n'a pas le groupe /admin.
|
||||
|
||||
Utilisé par toutes les routes de modification (annonces, fichiers,
|
||||
blocks). Aucune route admin n'est accessible sans token valide et sans
|
||||
appartenance au groupe Keycloak /admin (mitigation du risque "accès non
|
||||
autorisé à /admin", cf. tableau OWASP du dossier technique).
|
||||
"""
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
if not is_admin(user):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def client_ip():
|
||||
"""IP cliente, en tenant compte du proxy Caddy (X-Forwarded-For)."""
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.remote_addr
|
||||
25
web/services/block_service.py
Normal file
25
web/services/block_service.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""block_service.py — Logique métier du tableau de bord (widgets)."""
|
||||
|
||||
from models import block_model
|
||||
|
||||
|
||||
def list_blocks(db):
|
||||
return block_model.fetch_all(db)
|
||||
|
||||
|
||||
def add_block(db, block_type, column, data):
|
||||
if block_type not in {"iframe", "buttons", "html"}:
|
||||
raise ValueError(f"Type de bloc inconnu : {block_type}")
|
||||
if column not in {"left", "center", "right"}:
|
||||
raise ValueError(f"Colonne inconnue : {column}")
|
||||
return block_model.insert(db, block_type, column, data)
|
||||
|
||||
|
||||
def save_layout(db, layout):
|
||||
"""layout : liste de {id, column, position} après un drag & drop."""
|
||||
for item in layout:
|
||||
block_model.update_position(db, item["id"], item["column"], item["position"])
|
||||
|
||||
|
||||
def delete_block(db, block_id):
|
||||
return block_model.delete(db, block_id)
|
||||
42
web/services/evenement_service.py
Normal file
42
web/services/evenement_service.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""evenement_service.py — Logique métier du module Planning (EXF-09, EXF-10)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from models import evenement_model
|
||||
from services import audit_service
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _parse(dt_str):
|
||||
try:
|
||||
return datetime.fromisoformat(dt_str)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"Date invalide : {dt_str!r}") from exc
|
||||
|
||||
|
||||
def list_evenements(db):
|
||||
return evenement_model.fetch_all(db)
|
||||
|
||||
|
||||
def create_evenement(db, titre, description, date_debut, date_fin, createur_sub, ip=None):
|
||||
if not titre or not titre.strip():
|
||||
raise ValidationError("Le titre est obligatoire.")
|
||||
debut = _parse(date_debut)
|
||||
fin = _parse(date_fin)
|
||||
if fin < debut:
|
||||
raise ValidationError("La date de fin doit être postérieure à la date de début.")
|
||||
new_id = evenement_model.insert(db, titre.strip(), description, debut, fin, createur_sub)
|
||||
audit_service.log(db, "CREATE_EVENEMENT", f"evenement:{new_id}", "SUCCESS", createur_sub, ip)
|
||||
return new_id
|
||||
|
||||
|
||||
def delete_evenement(db, evenement_id, createur_sub, ip=None):
|
||||
existing = evenement_model.get(db, evenement_id)
|
||||
if not existing:
|
||||
raise LookupError(f"Événement {evenement_id} introuvable")
|
||||
rows = evenement_model.delete(db, evenement_id)
|
||||
audit_service.log(db, "DELETE_EVENEMENT", f"evenement:{evenement_id}", "SUCCESS", createur_sub, ip)
|
||||
return rows
|
||||
160
web/services/fichier_service.py
Normal file
160
web/services/fichier_service.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
fichier_service.py — Module de partage de fichiers (EXF-05 à EXF-08 du CDC).
|
||||
|
||||
Upload décomposé en 8 étapes indépendantes et testables unitairement
|
||||
(cf. dossier technique — Bloc 3, C2 "Décomposition algorithmique") :
|
||||
|
||||
1) Vérification de la présence du fichier dans la requête
|
||||
2) Vérification de la taille (avant lecture complète du flux)
|
||||
3) Vérification du type MIME réel (python-magic, analyse binaire du contenu)
|
||||
4) Vérification de l'extension déclarée (liste blanche)
|
||||
5) Génération d'un nom de fichier sécurisé (UUID4)
|
||||
6) Sauvegarde sur disque
|
||||
7) Enregistrement des métadonnées en base de données
|
||||
8) Journalisation de l'action dans audit_log
|
||||
|
||||
Chaque étape peut échouer indépendamment avec un message d'erreur explicite,
|
||||
ce qui facilite le test (voir tests/test_unit_fichier_service.py, UT-06 à UT-08)
|
||||
et le débogage (pas de bloc "tout ou rien").
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import magic # python-magic : détection MIME par analyse binaire (libmagic)
|
||||
|
||||
_HAS_MAGIC = True
|
||||
except ImportError: # pragma: no cover - environnement sans libmagic (dev/CI léger)
|
||||
_HAS_MAGIC = False
|
||||
|
||||
from config import Config
|
||||
from models import fichier_model
|
||||
from services import audit_service
|
||||
|
||||
|
||||
class FileValidationError(Exception):
|
||||
"""Erreur métier explicite pour chaque étape de validation d'upload."""
|
||||
|
||||
|
||||
# --- Étape 1 -----------------------------------------------------------------
|
||||
def step1_check_presence(file_storage):
|
||||
if file_storage is None or file_storage.filename == "":
|
||||
raise FileValidationError("Aucun fichier fourni dans la requête.")
|
||||
return file_storage
|
||||
|
||||
|
||||
# --- Étape 2 -----------------------------------------------------------------
|
||||
def step2_check_size(file_storage, max_size=None):
|
||||
max_size = max_size if max_size is not None else Config.MAX_UPLOAD_SIZE_BYTES
|
||||
file_storage.stream.seek(0, os.SEEK_END)
|
||||
size = file_storage.stream.tell()
|
||||
file_storage.stream.seek(0)
|
||||
if size == 0:
|
||||
raise FileValidationError("Le fichier est vide.")
|
||||
if size > max_size:
|
||||
raise FileValidationError(
|
||||
f"Fichier trop volumineux ({size} octets, max {max_size})."
|
||||
)
|
||||
return size
|
||||
|
||||
|
||||
# --- Étape 3 -----------------------------------------------------------------
|
||||
def step3_check_mime(file_storage, allowed_mimes=None):
|
||||
allowed_mimes = allowed_mimes or Config.ALLOWED_MIME_TYPES
|
||||
head = file_storage.stream.read(2048)
|
||||
file_storage.stream.seek(0)
|
||||
if _HAS_MAGIC:
|
||||
mime_type = magic.from_buffer(head, mime=True)
|
||||
else: # repli : type déclaré par le client (moins fiable, signalé dans les logs)
|
||||
mime_type = file_storage.mimetype or "application/octet-stream"
|
||||
if mime_type not in allowed_mimes:
|
||||
raise FileValidationError(f"Type de fichier non autorisé : {mime_type}")
|
||||
return mime_type
|
||||
|
||||
|
||||
# --- Étape 4 -----------------------------------------------------------------
|
||||
def step4_check_extension(filename, allowed_ext=None):
|
||||
allowed_ext = allowed_ext or Config.ALLOWED_EXTENSIONS
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext not in allowed_ext:
|
||||
raise FileValidationError(f"Extension non autorisée : .{ext}")
|
||||
return ext
|
||||
|
||||
|
||||
# --- Étape 5 -----------------------------------------------------------------
|
||||
def step5_generate_safe_name(ext):
|
||||
return f"{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
|
||||
# --- Étape 6 -----------------------------------------------------------------
|
||||
def step6_save_to_disk(file_storage, safe_name, upload_dir=None):
|
||||
# NB : upload_dir n'est PAS résolu en valeur par défaut de paramètre
|
||||
# (qui serait figée à l'import du module) mais lu dynamiquement dans le
|
||||
# corps de la fonction, afin de respecter Config.UPLOAD_DIR à l'instant
|
||||
# de l'appel (important notamment pour les tests, qui redirigent
|
||||
# UPLOAD_DIR vers un répertoire temporaire via monkeypatch).
|
||||
upload_dir = upload_dir or Config.UPLOAD_DIR
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
destination = os.path.join(upload_dir, safe_name)
|
||||
file_storage.save(destination)
|
||||
return destination
|
||||
|
||||
|
||||
# --- Étapes 7 & 8 --------------------------------------------------------------
|
||||
def step7_persist_metadata(db, original_name, safe_name, mime_type, size, uploader_sub):
|
||||
return fichier_model.insert(db, original_name, safe_name, mime_type, size, uploader_sub)
|
||||
|
||||
|
||||
def step8_log_audit(db, fichier_id, uploader_sub, ip=None):
|
||||
audit_service.log(db, "UPLOAD_FILE", f"fichier:{fichier_id}", "SUCCESS", uploader_sub, ip)
|
||||
|
||||
|
||||
def handle_upload(db, file_storage, uploader_sub, ip=None):
|
||||
"""Orchestre les 8 étapes. Lève FileValidationError au premier échec."""
|
||||
step1_check_presence(file_storage)
|
||||
size = step2_check_size(file_storage)
|
||||
mime_type = step3_check_mime(file_storage)
|
||||
ext = step4_check_extension(file_storage.filename)
|
||||
safe_name = step5_generate_safe_name(ext)
|
||||
step6_save_to_disk(file_storage, safe_name)
|
||||
fichier_id = step7_persist_metadata(
|
||||
db, file_storage.filename, safe_name, mime_type, size, uploader_sub
|
||||
)
|
||||
step8_log_audit(db, fichier_id, uploader_sub, ip)
|
||||
return fichier_id
|
||||
|
||||
|
||||
def list_fichiers(db):
|
||||
return fichier_model.fetch_all(db)
|
||||
|
||||
|
||||
def prepare_download(db, fichier_id, requester_sub, is_admin, ip=None):
|
||||
"""Vérifie les droits avant de renvoyer le chemin disque du fichier.
|
||||
|
||||
Règle métier : un fichier est téléchargeable par son propriétaire ou par
|
||||
un administrateur (mitigation OWASP A01 — Broken Access Control /
|
||||
"accès aux ressources d'autrui", cf. tests TS-05).
|
||||
"""
|
||||
fichier = fichier_model.get(db, fichier_id)
|
||||
if not fichier:
|
||||
raise FileNotFoundError(f"Fichier {fichier_id} introuvable")
|
||||
if fichier["uploader_sub"] != requester_sub and not is_admin:
|
||||
audit_service.log(db, "DOWNLOAD_FILE", f"fichier:{fichier_id}", "REFUSED", requester_sub, ip)
|
||||
raise PermissionError("Accès refusé à ce fichier.")
|
||||
audit_service.log(db, "DOWNLOAD_FILE", f"fichier:{fichier_id}", "SUCCESS", requester_sub, ip)
|
||||
return os.path.join(Config.UPLOAD_DIR, fichier["nom_stocke"]), fichier["nom_original"]
|
||||
|
||||
|
||||
def delete_fichier(db, fichier_id, requester_sub, is_admin, ip=None):
|
||||
fichier = fichier_model.get(db, fichier_id)
|
||||
if not fichier:
|
||||
raise FileNotFoundError(f"Fichier {fichier_id} introuvable")
|
||||
if fichier["uploader_sub"] != requester_sub and not is_admin:
|
||||
audit_service.log(db, "DELETE_FILE", f"fichier:{fichier_id}", "REFUSED", requester_sub, ip)
|
||||
raise PermissionError("Accès refusé à ce fichier.")
|
||||
path = os.path.join(Config.UPLOAD_DIR, fichier["nom_stocke"])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
fichier_model.delete(db, fichier_id)
|
||||
audit_service.log(db, "DELETE_FILE", f"fichier:{fichier_id}", "SUCCESS", requester_sub, ip)
|
||||
69
web/services/rgpd_service.py
Normal file
69
web/services/rgpd_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
rgpd_service.py — Droit à l'effacement / anonymisation RGPD (art. 17).
|
||||
|
||||
GestHub ne stocke pas de compte utilisateur : l'unique donnée personnelle
|
||||
conservée est le `sub` OIDC Keycloak, référencé dans annonces, fichiers,
|
||||
evenements et audit_log (cf. rgpd_model.py). "Supprimer un utilisateur" au
|
||||
sens RGPD consiste donc à remplacer ce `sub` partout où il apparaît par un
|
||||
pseudonyme irréversible, sans toucher au contenu produit (titres, fichiers,
|
||||
événements) qui reste une donnée d'activité du service, pas une donnée
|
||||
personnelle une fois l'auteur dé-identifié.
|
||||
|
||||
Pseudonyme = HMAC-SHA256(pepper, sub), tronqué et préfixé "anon:" pour tenir
|
||||
dans la colonne VARCHAR(64) et rester reconnaissable dans les journaux/DB.
|
||||
Le pepper (RGPD_ANON_PEPPER, ou SECRET_KEY à défaut) n'est jamais stocké
|
||||
dans la base : sans lui, remonter du pseudonyme au `sub` d'origine est
|
||||
calculatoirement infaisable (irréversibilité, cf. RGPD art. 17 §1).
|
||||
Le hash reste déterministe pour un même `sub` : les quatre tables reçoivent
|
||||
donc le même pseudonyme, ce qui permet de vérifier a posteriori que la purge
|
||||
a bien été appliquée partout, sans jamais réexposer l'identité d'origine.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from models import rgpd_model
|
||||
from services import audit_service
|
||||
|
||||
ANON_PREFIX = "anon:"
|
||||
_HASH_HEX_LEN = 64 - len(ANON_PREFIX) # tient dans VARCHAR(64)
|
||||
|
||||
|
||||
class InvalidSubError(Exception):
|
||||
"""Levée quand le `sub` cible est vide ou manifestement invalide."""
|
||||
|
||||
|
||||
def _anon_value(pepper, target_sub):
|
||||
digest = hmac.new(pepper.encode("utf-8"), target_sub.encode("utf-8"), hashlib.sha256)
|
||||
return ANON_PREFIX + digest.hexdigest()[:_HASH_HEX_LEN]
|
||||
|
||||
|
||||
def anonymize_user(db, target_sub, pepper, actor_sub=None, ip=None):
|
||||
"""Anonymise toutes les données rattachées à `target_sub`.
|
||||
|
||||
Retourne {"anonymized": bool, "pseudonym": str, "rows": {...}}.
|
||||
`anonymized` est False si aucune ligne ne référençait ce `sub` (rien à
|
||||
faire — permet à la route de répondre 404 plutôt que de journaliser une
|
||||
action fictive).
|
||||
"""
|
||||
target_sub = (target_sub or "").strip()
|
||||
if not target_sub:
|
||||
raise InvalidSubError("Le sub cible est obligatoire.")
|
||||
if target_sub.startswith(ANON_PREFIX):
|
||||
raise InvalidSubError("Cet utilisateur est déjà anonymisé.")
|
||||
|
||||
pseudonym = _anon_value(pepper, target_sub)
|
||||
rows = rgpd_model.anonymize(db, target_sub, pseudonym)
|
||||
total = sum(rows.values())
|
||||
|
||||
if total > 0:
|
||||
audit_service.log(
|
||||
db,
|
||||
"RGPD_ANONYMIZE_USER",
|
||||
f"user:{pseudonym}",
|
||||
"SUCCESS",
|
||||
actor_sub,
|
||||
ip,
|
||||
)
|
||||
|
||||
return {"anonymized": total > 0, "pseudonym": pseudonym, "rows": rows}
|
||||
Reference in New Issue
Block a user