Trace les écritures et fiabilise les migrations
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 19:01:11 +00:00
parent 05b2002d02
commit 7d8d67a9d2
13 changed files with 234 additions and 190 deletions

View file

@ -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):

58
app_new/core/audit.py Normal file
View file

@ -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

View file

@ -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',
]

View file

@ -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),
)

View file

@ -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/<int:id>/permissions', methods=['POST'])
@login_required
@admin_required

View file

@ -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 ====================

View file

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

View file

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

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Journal d'audit — GMAO{% endblock %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-3"><i class="bi bi-journal-lock"></i> Journal d'audit</h1>
<p class="text-muted">Écritures HTTP réussies ou refusées. Les mots de passe, jetons, clés et PIN sont masqués.</p>
<div class="table-responsive"><table class="table table-sm table-hover align-middle">
<thead><tr><th>Date</th><th>Utilisateur</th><th>Action</th><th>Route</th><th>Résultat</th><th>Données</th></tr></thead>
<tbody>{% for log in logs.items %}<tr>
<td class="text-nowrap">{{ log.created_at|datetime_fmt }}</td><td>{{ log.username }}</td>
<td><span class="badge text-bg-secondary">{{ log.action }}</span></td><td><code>{{ log.path }}</code></td>
<td><span class="badge {% if log.status_code < 400 %}text-bg-success{% else %}text-bg-warning{% endif %}">{{ log.status_code }}</span></td>
<td class="small text-break" style="max-width:30rem">{{ log.payload or '—' }}</td>
</tr>{% else %}<tr><td colspan="6" class="text-center text-muted">Aucune écriture enregistrée.</td></tr>{% endfor %}</tbody>
</table></div>
<nav><ul class="pagination">
{% if logs.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('admin.audit_logs', page=logs.prev_num) }}">Précédent</a></li>{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ logs.page }} / {{ logs.pages or 1 }}</span></li>
{% if logs.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('admin.audit_logs', page=logs.next_num) }}">Suivant</a></li>{% endif %}
</ul></nav>
</div>
{% endblock %}

View file

@ -1,163 +0,0 @@
{% extends "base.html" %}
{% block title %}Gestion des bases — GMAO{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h2 class="mb-4"><i class="bi bi-database"></i> Gestion des bases de données</h2>
<!-- Actions principales -->
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-gear"></i> Actions
</div>
<div class="card-body">
<div class="row g-3">
<!-- Créer une nouvelle base -->
<div class="col-md-4">
<div class="card h-100 border-primary">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-plus-circle text-primary"></i> Nouvelle base</h5>
<p class="card-text small">Créer une nouvelle base de données vide.</p>
<form method="POST" action="{{ url_for('setup_wizard.create') }}">
<div class="input-group input-group-sm">
<input type="text" class="form-control" name="name" placeholder="Nom (ex: test)" required pattern="[a-zA-Z0-9_-]+" title="Caractères alphanumériques, tirets et underscores uniquement">
<span class="input-group-text">.db</span>
<button type="submit" class="btn btn-primary"><i class="bi bi-plus"></i></button>
</div>
</form>
</div>
</div>
</div>
<!-- Sauvegarder la base actuelle -->
<div class="col-md-4">
<div class="card h-100 border-success">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-download text-success"></i> Sauvegarder</h5>
<p class="card-text small">Créer une copie horodatée de la base active.</p>
<form method="POST" action="{{ url_for('setup_wizard.backup') }}">
<button type="submit" class="btn btn-success btn-sm w-100">
<i class="bi bi-download"></i> Créer une sauvegarde
</button>
</form>
</div>
</div>
</div>
<!-- Recharger la base à chaud -->
<div class="col-md-4">
<div class="card h-100 border-info">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-arrow-repeat text-info"></i> Recharger</h5>
<p class="card-text small">Recharger la base de données active sans redémarrer.</p>
<form method="POST" action="{{ url_for('setup_wizard.reload_db') }}">
<button type="submit" class="btn btn-info btn-sm w-100">
<i class="bi bi-arrow-repeat"></i> Recharger la base
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Liste des bases -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-collection"></i> Bases de données disponibles</span>
<form method="POST" action="{{ url_for('setup_wizard.delete_db', filename='dummy') }}" onsubmit="return confirm('Vider la corbeille ?');" id="clearTrashForm">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="clearTrash()">
<i class="bi bi-trash"></i> Vider la corbeille
</button>
</form>
</div>
<div class="card-body p-0">
{% if databases %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Fichier</th>
<th>Taille</th>
<th>Modifié le</th>
<th>Statut</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for db in databases %}
<tr {% if db.is_current %}class="table-primary"{% endif %}>
<td>
<code>{{ db.filename }}</code>
{% if db.is_current %}
<span class="badge bg-primary ms-2">Active</span>
{% endif %}
</td>
<td>{{ (db.size / 1024) | round(1) }} Ko</td>
<td>{{ db.modified.strftime('%d/%m/%Y %H:%M') }}</td>
<td>
{% if db.is_current %}
<span class="badge bg-success"><i class="bi bi-check-circle"></i> En cours</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
{% if not db.is_current %}
<form method="POST" action="{{ url_for('setup_wizard.switch_db', filename=db.filename) }}" style="display:inline;" onsubmit="return confirm('Basculer vers cette base ?');">
<button type="submit" class="btn btn-sm btn-outline-primary" title="Activer cette base">
<i class="bi bi-arrow-repeat"></i> Activer
</button>
</form>
<form method="POST" action="{{ url_for('setup_wizard.delete_db', filename=db.filename) }}" style="display:inline;" onsubmit="return confirm('Supprimer cette base ?');">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
<i class="bi bi-trash"></i>
</button>
</form>
{% else %}
<span class="text-muted small">Base active</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox fs-1"></i>
<p>Aucune base de données trouvée.</p>
</div>
{% endif %}
</div>
</div>
<!-- Avertissement -->
<div class="alert alert-warning mt-4" role="alert">
<i class="bi bi-exclamation-triangle"></i> <strong>Attention :</strong>
Le basculement vers une autre base nécessite un rechargement pour prendre effet.
Les bases supprimées sont déplacées vers une corbeille.
</div>
</div>
</div>
</div>
<script>
function clearTrash() {
if (confirm('Vider la corbeille ? Cette action est irréversible.')) {
fetch('/setup-wizard/admin/clear-trash', {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.error || 'Erreur');
}
});
}
}
</script>
{% endblock %}

View file

@ -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 - <<PYEOF "${MARIADB_DATABASE:-gmao_db}"
import sys
# Alembic : une base neuve est créée depuis les modèles, une base importée du
# dump est rattachée au bon point historique, puis toutes les évolutions
# suivantes passent obligatoirement par les migrations.
echo "Application des migrations Alembic..."
ALEMBIC_STATE="$(python3 - <<'PYEOF'
from sqlalchemy import inspect
from app_new import create_app
db_name = sys.argv[1]
app = create_app()
with app.app_context():
engine = app.extensions['migrate'].db.engine
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"Tables presentes: {len(tables)}")
if 'alembic_version' not in tables:
print("Note: alembic_version absent, base probablement initialisee par dump SQL")
tables = set(inspect(engine).get_table_names())
if 'alembic_version' in tables:
print('tracked')
elif 'users' not in tables:
print('empty')
elif 'closure_work_days' in tables:
print('imported_f1')
else:
print("alembic_version present, migrations suivies")
print('imported_e0')
PYEOF
)"
case "$ALEMBIC_STATE" in
empty)
echo "Base vide : création du schéma courant."
python3 - <<'PYEOF'
from app_new import create_app, db
app = create_app()
with app.app_context():
db.create_all()
PYEOF
flask --app wsgi:app db stamp head
;;
imported_e0)
echo "Dump historique détecté : rattachement à e0f4a5b6c7d8."
flask --app wsgi:app db stamp e0f4a5b6c7d8
flask --app wsgi:app db upgrade
;;
imported_f1)
echo "Base importée déjà enrichie : rattachement à f1a5b6c7d8e9."
flask --app wsgi:app db stamp f1a5b6c7d8e9
flask --app wsgi:app db upgrade
;;
tracked)
flask --app wsgi:app db upgrade
;;
*)
echo "Etat Alembic inattendu: $ALEMBIC_STATE" >&2
exit 1
;;
esac
echo "Lancement de l'application..."

View file

@ -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")

View file

@ -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