transfer from private server/git server

This commit is contained in:
M1n-0
2026-08-21 09:12:02 +02:00
parent 45c8982178
commit 6ecb014238
75 changed files with 4344 additions and 274 deletions

1
web/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Couche services/ — logique métier. Les routes n'appellent jamais les models/ directement."""

View 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

View 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)

View 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

View 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)

View 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

View 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)

View 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}