From 7d8d67a9d28c8c0242553487ca5393ebaa35c422 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 19:01:11 +0000 Subject: [PATCH] =?UTF-8?q?Trace=20les=20=C3=A9critures=20et=20fiabilise?= =?UTF-8?q?=20les=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/__init__.py | 6 +- app_new/core/audit.py | 58 +++++++ app_new/core/models/__init__.py | 3 +- app_new/core/models/audit.py | 25 +++ app_new/core/routes/admin.py | 11 ++ app_new/equipments/meters.py | 5 +- app_new/equipments/restrictions.py | 5 +- app_new/equipments/scheduled.py | 11 +- app_new/templates/admin/audit_logs.html | 22 +++ app_new/templates/setup_wizard/admin.html | 163 ------------------ docker/entrypoint.sh | 55 ++++-- .../versions/b3c7d8e9f0a1_add_audit_logs.py | 39 +++++ tests/integration/test_audit_log.py | 21 +++ 13 files changed, 234 insertions(+), 190 deletions(-) create mode 100644 app_new/core/audit.py create mode 100644 app_new/core/models/audit.py create mode 100644 app_new/templates/admin/audit_logs.html delete mode 100644 app_new/templates/setup_wizard/admin.html create mode 100644 migrations/versions/b3c7d8e9f0a1_add_audit_logs.py create mode 100644 tests/integration/test_audit_log.py diff --git a/app_new/__init__.py b/app_new/__init__.py index 0b17f67..43ae0ea 100644 --- a/app_new/__init__.py +++ b/app_new/__init__.py @@ -58,6 +58,8 @@ def create_app(config_name='default'): from .core.authorization import enforce_route_permission, has_permission app.before_request(enforce_route_permission) app.jinja_env.globals["has_permission"] = has_permission + from .core.audit import record_mutation + app.after_request(record_mutation) # Configuration du login manager login_manager.login_view = 'auth.login' @@ -210,10 +212,6 @@ def create_app(config_name='default'): app.jinja_env.filters["text_color"] = bootstrap_text_color app.jinja_env.globals["timedelta"] = timedelta - # Création des tables (fallback si migrations pas encore appliquees) - with app.app_context(): - db.create_all() - # Gestionnaires d'erreurs personnalisées @app.errorhandler(404) def not_found_error(e): diff --git a/app_new/core/audit.py b/app_new/core/audit.py new file mode 100644 index 0000000..c71ee87 --- /dev/null +++ b/app_new/core/audit.py @@ -0,0 +1,58 @@ +"""Capture centralisée et expurgée des écritures HTTP.""" +import json +import re + +from flask import current_app, request +from flask_login import current_user + +from ..extensions import db +from .models.audit import AuditLog + + +SENSITIVE_MARKERS = ("password", "mot_de_passe", "secret", "token", "api_key", "apikey", "pin") + + +def _redacted_payload(): + data = request.get_json(silent=True) if request.is_json else request.form.to_dict(flat=False) + if not isinstance(data, dict): + return None + clean = {} + for key, value in data.items(): + clean[key] = "[MASQUÉ]" if any(marker in key.lower() for marker in SENSITIVE_MARKERS) else value + encoded = json.dumps(clean, ensure_ascii=False, default=str) + return encoded[:8000] + + +def _entity_from_path(): + endpoint = request.endpoint or "" + blueprint = endpoint.split(".", 1)[0] or None + match = re.search(r"/(\d+)(?:/|$)", request.path) + return blueprint, match.group(1) if match else None + + +def record_mutation(response): + if request.method in {"GET", "HEAD", "OPTIONS"} or response.status_code >= 500: + return response + entity_type, entity_id = _entity_from_path() + values = { + "user_id": current_user.id if current_user.is_authenticated else None, + "username": current_user.username if current_user.is_authenticated else "anonyme", + "action": request.method, + "endpoint": request.endpoint, + "path": request.path[:500], + "entity_type": entity_type, + "entity_id": entity_id, + "payload": _redacted_payload(), + "status_code": response.status_code, + "ip_address": (request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip())[:64], + } + try: + # Transaction séparée : le journal ne valide jamais par accident des + # changements laissés en attente par une vue métier. + with db.engine.begin() as connection: + connection.execute(AuditLog.__table__.insert().values(**values)) + except Exception: + # Une panne du journal ne doit pas transformer une réponse métier déjà + # produite en erreur 500 ; l'erreur reste visible dans les logs serveur. + current_app.logger.exception("Impossible d'enregistrer l'opération dans le journal d'audit") + return response diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 29a5d31..ebee400 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -17,6 +17,7 @@ from .planning import ( TechnicianAvailability, AdminTask, ZoneAccessRule ) from .settings import AppSettings +from .audit import AuditLog __all__ = [ 'User', 'Staff', @@ -29,5 +30,5 @@ __all__ = [ 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', - 'AppSettings', + 'AppSettings', 'AuditLog', ] diff --git a/app_new/core/models/audit.py b/app_new/core/models/audit.py new file mode 100644 index 0000000..1f8e454 --- /dev/null +++ b/app_new/core/models/audit.py @@ -0,0 +1,25 @@ +"""Journal append-only des opérations sensibles.""" +from datetime import datetime, timezone + +from ...extensions import db + + +class AuditLog(db.Model): + __tablename__ = "audit_logs" + + id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), index=True) + username = db.Column(db.String(80), nullable=False, default="système") + action = db.Column(db.String(16), nullable=False) + endpoint = db.Column(db.String(160), nullable=True) + path = db.Column(db.String(500), nullable=False) + entity_type = db.Column(db.String(80), nullable=True, index=True) + entity_id = db.Column(db.String(80), nullable=True, index=True) + payload = db.Column(db.Text, nullable=True) + status_code = db.Column(db.SmallInteger, nullable=False) + ip_address = db.Column(db.String(64), nullable=True) + created_at = db.Column( + db.DateTime, nullable=False, index=True, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), + ) + diff --git a/app_new/core/routes/admin.py b/app_new/core/routes/admin.py index e73a0de..95283c8 100644 --- a/app_new/core/routes/admin.py +++ b/app_new/core/routes/admin.py @@ -135,6 +135,17 @@ def permissions(): ) +@admin_bp.route('/audit') +@login_required +@admin_required +def audit_logs(): + """Consultation du journal immuable des écritures HTTP.""" + from ..models.audit import AuditLog + page = request.args.get('page', 1, type=int) + logs = AuditLog.query.order_by(AuditLog.created_at.desc()).paginate(page=page, per_page=50) + return render_template('admin/audit_logs.html', logs=logs) + + @admin_bp.route('/users//permissions', methods=['POST']) @login_required @admin_required diff --git a/app_new/equipments/meters.py b/app_new/equipments/meters.py index 3e3710d..c2dfb8c 100644 --- a/app_new/equipments/meters.py +++ b/app_new/equipments/meters.py @@ -100,7 +100,7 @@ def add_meter_reading(id): meter_id = data.get('meter_id') value = data.get('value') - meter = Meter.query.get_or_404(meter_id) + meter = Meter.query.filter_by(id=meter_id, equipment_id=id).first_or_404() reading = MeterReading( meter_id=meter_id, @@ -143,7 +143,7 @@ def remove_consumable(id): data = request.get_json() ec_id = data.get('ec_id') - ec = EquipmentConsumable.query.get_or_404(ec_id) + ec = EquipmentConsumable.query.filter_by(id=ec_id, equipment_id=id).first_or_404() db.session.delete(ec) db.session.commit() @@ -151,4 +151,3 @@ def remove_consumable(id): # ==================== DOCUMENTS ==================== - diff --git a/app_new/equipments/restrictions.py b/app_new/equipments/restrictions.py index 6efbe3c..691de15 100644 --- a/app_new/equipments/restrictions.py +++ b/app_new/equipments/restrictions.py @@ -101,10 +101,11 @@ def delete_restriction(id, restriction_id): """Supprimer une restriction.""" from ..core.models.equipment import EquipmentRestriction - restriction = EquipmentRestriction.query.get_or_404(restriction_id) + restriction = EquipmentRestriction.query.filter_by( + id=restriction_id, equipment_id=id + ).first_or_404() db.session.delete(restriction) db.session.commit() flash('Restriction supprimée', 'success') return redirect(url_for('equipments.restrictions', id=id)) - diff --git a/app_new/equipments/scheduled.py b/app_new/equipments/scheduled.py index f567898..4ae0d5e 100644 --- a/app_new/equipments/scheduled.py +++ b/app_new/equipments/scheduled.py @@ -66,7 +66,7 @@ def new_scheduled_task(id): from ..core.models.user import User rooms = Room.query.order_by(Room.name).all() - technicians = User.query.filter(User.role.in_(['technician', 'admin'])).all() + technicians = User.query.filter(User.role.in_(['technicien', 'tech', 'technician', 'responsable_gmao', 'admin'])).all() companies = Company.query.order_by(Company.name).all() return render_template('equipments/scheduled_task_form.html', equipment=equipment, rooms=rooms, technicians=technicians, companies=companies) @@ -79,7 +79,7 @@ def edit_scheduled_task(id, task_id): from ..core.models.college import Room from datetime import datetime - task = ScheduledTask.query.get_or_404(task_id) + task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404() equipment = Equipment.query.get_or_404(id) if request.method == 'POST': @@ -101,7 +101,7 @@ def edit_scheduled_task(id, task_id): from ..core.models.user import User rooms = Room.query.order_by(Room.name).all() - technicians = User.query.filter(User.role.in_(['technician', 'admin'])).all() + technicians = User.query.filter(User.role.in_(['technicien', 'tech', 'technician', 'responsable_gmao', 'admin'])).all() companies = Company.query.order_by(Company.name).all() return render_template('equipments/scheduled_task_form.html', equipment=equipment, task=task, rooms=rooms, technicians=technicians, companies=companies) @@ -112,7 +112,7 @@ def delete_scheduled_task(id, task_id): """Supprimer une tâche planifiée.""" from ..core.models.planning import ScheduledTask - task = ScheduledTask.query.get_or_404(task_id) + task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404() db.session.delete(task) db.session.commit() flash('Tâche supprimée', 'success') @@ -129,7 +129,7 @@ def create_intervention_from_task(id, task_id): from ..core.models.user import User from flask_login import current_user - task = ScheduledTask.query.get_or_404(task_id) + task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404() equipment = Equipment.query.get_or_404(id) if request.method == 'POST': @@ -160,4 +160,3 @@ def create_intervention_from_task(id, task_id): rooms=rooms, technicians=technicians, companies=companies) - diff --git a/app_new/templates/admin/audit_logs.html b/app_new/templates/admin/audit_logs.html new file mode 100644 index 0000000..3697b8e --- /dev/null +++ b/app_new/templates/admin/audit_logs.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Journal d'audit — GMAO{% endblock %} +{% block content %} +
+

Journal d'audit

+

Écritures HTTP réussies ou refusées. Les mots de passe, jetons, clés et PIN sont masqués.

+
+ + {% for log in logs.items %} + + + + + {% else %}{% endfor %} +
DateUtilisateurActionRouteRésultatDonnées
{{ log.created_at|datetime_fmt }}{{ log.username }}{{ log.action }}{{ log.path }}{{ log.status_code }}{{ log.payload or '—' }}
Aucune écriture enregistrée.
+ +
+{% endblock %} diff --git a/app_new/templates/setup_wizard/admin.html b/app_new/templates/setup_wizard/admin.html deleted file mode 100644 index c4e67b8..0000000 --- a/app_new/templates/setup_wizard/admin.html +++ /dev/null @@ -1,163 +0,0 @@ -{% extends "base.html" %} -{% block title %}Gestion des bases — GMAO{% endblock %} - -{% block content %} -
-
-
-

Gestion des bases de données

- - -
-
- Actions -
-
-
- -
-
-
-
Nouvelle base
-

Créer une nouvelle base de données vide.

-
-
- - .db - -
-
-
-
-
- - -
-
-
-
Sauvegarder
-

Créer une copie horodatée de la base active.

-
- -
-
-
-
- - -
-
-
-
Recharger
-

Recharger la base de données active sans redémarrer.

-
- -
-
-
-
-
-
-
- - -
-
- Bases de données disponibles -
- -
-
-
- {% if databases %} -
- - - - - - - - - - - - {% for db in databases %} - - - - - - - - {% endfor %} - -
FichierTailleModifié leStatutActions
- {{ db.filename }} - {% if db.is_current %} - Active - {% endif %} - {{ (db.size / 1024) | round(1) }} Ko{{ db.modified.strftime('%d/%m/%Y %H:%M') }} - {% if db.is_current %} - En cours - {% else %} - Inactive - {% endif %} - - {% if not db.is_current %} -
- -
-
- -
- {% else %} - Base active - {% endif %} -
-
- {% else %} -
- -

Aucune base de données trouvée.

-
- {% endif %} -
-
- - - -
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index acb7d41..8929a08 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -32,25 +32,58 @@ echo "MariaDB OK" mkdir -p /app/data/cache /app/app_new/uploads /app/app_new/instance chown -R 1000:1000 /app/data /app/app_new/uploads /app/app_new/instance 2>/dev/null || true -# Alembic -echo "Verification Alembic..." -python3 - <&2 + exit 1 + ;; +esac echo "Lancement de l'application..." diff --git a/migrations/versions/b3c7d8e9f0a1_add_audit_logs.py b/migrations/versions/b3c7d8e9f0a1_add_audit_logs.py new file mode 100644 index 0000000..a4f4996 --- /dev/null +++ b/migrations/versions/b3c7d8e9f0a1_add_audit_logs.py @@ -0,0 +1,39 @@ +"""Ajoute le journal d'audit. + +Revision ID: b3c7d8e9f0a1 +Revises: a2b6c7d8e9f0 +Create Date: 2026-08-14 +""" +from alembic import op +import sqlalchemy as sa + +revision = "b3c7d8e9f0a1" +down_revision = "a2b6c7d8e9f0" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "audit_logs", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("username", sa.String(length=80), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("endpoint", sa.String(length=160), nullable=True), + sa.Column("path", sa.String(length=500), nullable=False), + sa.Column("entity_type", sa.String(length=80), nullable=True), + sa.Column("entity_id", sa.String(length=80), nullable=True), + sa.Column("payload", sa.Text(), nullable=True), + sa.Column("status_code", sa.SmallInteger(), nullable=False), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for column in ("user_id", "entity_type", "entity_id", "created_at"): + op.create_index(f"ix_audit_logs_{column}", "audit_logs", [column]) + + +def downgrade(): + op.drop_table("audit_logs") diff --git a/tests/integration/test_audit_log.py b/tests/integration/test_audit_log.py new file mode 100644 index 0000000..6a63f80 --- /dev/null +++ b/tests/integration/test_audit_log.py @@ -0,0 +1,21 @@ +"""Le journal d'audit doit être utile sans enregistrer de secrets.""" +from app_new.core.models.audit import AuditLog + + +def test_login_attempt_is_audited_and_password_is_redacted(client, app): + response = client.post('/auth/login', data={ + 'username': 'personne-inconnue', + 'password': 'secret-qui-ne-doit-pas-etre-stocke', + }) + assert response.status_code == 200 + + with app.app_context(): + entry = AuditLog.query.filter_by(endpoint='auth.login').order_by(AuditLog.id.desc()).first() + assert entry is not None + assert entry.action == 'POST' + assert 'secret-qui-ne-doit-pas-etre-stocke' not in entry.payload + assert '[MASQUÉ]' in entry.payload + + +def test_audit_page_is_admin_only(client, app): + assert client.get('/admin/audit').status_code == 302