Trace les écritures et fiabilise les migrations
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
05b2002d02
commit
7d8d67a9d2
13 changed files with 234 additions and 190 deletions
|
|
@ -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
58
app_new/core/audit.py
Normal 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
|
||||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
25
app_new/core/models/audit.py
Normal file
25
app_new/core/models/audit.py
Normal 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),
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
22
app_new/templates/admin/audit_logs.html
Normal file
22
app_new/templates/admin/audit_logs.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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..."
|
||||
|
||||
|
|
|
|||
39
migrations/versions/b3c7d8e9f0a1_add_audit_logs.py
Normal file
39
migrations/versions/b3c7d8e9f0a1_add_audit_logs.py
Normal 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")
|
||||
21
tests/integration/test_audit_log.py
Normal file
21
tests/integration/test_audit_log.py
Normal 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
|
||||
Loading…
Reference in a new issue