Sécurise et supervise les intégrations externes
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 21:46:00 +00:00
parent ed6675b9d3
commit 8f276b5c47
8 changed files with 80 additions and 12 deletions

View file

@ -67,6 +67,7 @@ COPY --chown=1000:1000 migrations /app/migrations
COPY --chown=1000:1000 VERSION /app/VERSION
COPY --chown=1000:1000 wsgi.py run_app_new.py /app/
COPY --chown=1000:1000 gmao_watchdog.py ent_watchdog.py pronote_watchdog.py watchdog_dnd.py /app/
COPY --chown=1000:1000 watchdog_gate.py /app/watchdog_gate.py
COPY --chown=1000:1000 docker/backup.py docker/backup_loop.py /app/
# Tracabilite du build (surchargeable avec --build-arg GIT_VERSION=...)

View file

@ -59,7 +59,8 @@ def index():
has_key=bool(api_key),
model=model,
model_status=model_status,
model_error=model_error)
model_error=model_error,
human_approval=True)
@ai_config_bp.route('/save', methods=['POST'])
@ -68,7 +69,7 @@ def save():
"""Sauvegarde la configuration IA."""
# Cle API
api_key = request.form.get('api_key', '').strip()
if api_key and api_key != '***' and not api_key.startswith('***'):
if api_key and '...' not in api_key and api_key != '***' and not api_key.startswith('***'):
AppSettings.set('openrouter_api_key', api_key,
description='Cle API OpenRouter pour l\'interpretation des emails',
is_encrypted=True)
@ -78,6 +79,8 @@ def save():
if model:
AppSettings.set('openrouter_model', model,
description='Modele OpenRouter pour l\'interpretation IA')
AppSettings.set('ai_requires_human_approval', 'true',
description='Validation humaine obligatoire avant action métier')
flash('Configuration IA sauvegardee.', 'success')
return redirect(url_for('ai_config.index'))
@ -155,4 +158,4 @@ def api_status():
else:
return jsonify({'status': 'error', 'model': model, 'message': f'HTTP {resp.status_code}'})
except Exception as e:
return jsonify({'status': 'error', 'model': model, 'message': str(e)[:100]})
return jsonify({'status': 'error', 'model': model, 'message': str(e)[:100]})

View file

@ -11,6 +11,7 @@
</nav>
<h2 class="h4 mb-3"><i class="bi bi-robot"></i> Configuration IA (OpenRouter)</h2>
<div class="alert alert-warning"><i class="bi bi-person-check"></i> <strong>Validation humaine obligatoire :</strong> lIA produit uniquement des propositions. Elle ne crée, ne modifie et ne clôture jamais automatiquement une intervention.</div>
<div class="row">
<div class="col-lg-8">
@ -129,4 +130,4 @@ document.getElementById('btn-test-model').addEventListener('click', async functi
btn.innerHTML = original;
});
</script>
{% endblock %}
{% endblock %}

View file

@ -3,6 +3,12 @@ Modèles pour les paramètres de l'application
"""
from datetime import datetime, timezone
from app_new.extensions import db
import os
def _cipher():
from cryptography.fernet import Fernet
key = os.environ.get('OUTLOOK_ENCRYPTION_KEY')
return Fernet(key.encode()) if key else None
class AppSettings(db.Model):
@ -22,22 +28,37 @@ class AppSettings(db.Model):
"""Récupère un paramètre par sa clé."""
setting = AppSettings.query.filter_by(key=key).first()
if setting:
if setting.is_encrypted and setting.value and setting.value.startswith('enc:'):
cipher = _cipher()
if not cipher:
return default
try:
return cipher.decrypt(setting.value[4:].encode()).decode()
except Exception:
return default
return setting.value
return default
@staticmethod
def set(key, value, description=None, is_encrypted=False):
"""Définit un paramètre."""
stored_value = value
if is_encrypted and value:
cipher = _cipher()
if not cipher:
raise RuntimeError("OUTLOOK_ENCRYPTION_KEY est requise pour stocker ce secret.")
stored_value = 'enc:' + cipher.encrypt(value.encode()).decode()
setting = AppSettings.query.filter_by(key=key).first()
if setting:
setting.value = value
setting.value = stored_value
setting.is_encrypted = is_encrypted
setting.updated_at = datetime.now(timezone.utc)
if description:
setting.description = description
else:
setting = AppSettings(
key=key,
value=value,
value=stored_value,
description=description,
is_encrypted=is_encrypted
)
@ -61,4 +82,4 @@ class AppSettings(db.Model):
)
def __repr__(self):
return f'<AppSettings {self.key}>'
return f'<AppSettings {self.key}>'

View file

@ -26,7 +26,7 @@ def index():
outlook_accounts = OutlookAccount.query.filter_by(user_id=current_user.id).all()
# Recuperer la cle API et le modele OpenRouter
openrouter_key = AppSettings.get('openrouter_api_key') or ''
openrouter_key = '••••••••' if AppSettings.get('openrouter_api_key') else ''
openrouter_model = AppSettings.get('openrouter_model', 'poolside/laguna-s-2.1:free')
ollama_model = AppSettings.get('ollama_model', 'gemma3:4b-it')
@ -80,8 +80,8 @@ def save():
# Configuration IA
from app_new.core.models.settings import AppSettings
api_key = request.form.get('openrouter_api_key', '').strip()
if api_key:
AppSettings.set('openrouter_api_key', api_key)
if api_key and api_key != '••••••••':
AppSettings.set('openrouter_api_key', api_key, is_encrypted=True)
model = request.form.get('openrouter_model', '').strip()
if model:
AppSettings.set('openrouter_model', model)
@ -150,4 +150,4 @@ def test():
'success': True,
'analysis': result,
'context_summary': context.get_full_context()[:200] + '...' if context.get_full_context() else 'Vide'
})
})

View file

@ -1,6 +1,8 @@
"""Logs centralises des watchdogs."""
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
from flask_login import login_required
from datetime import datetime, timezone, timedelta
from app_new.core.models.settings import AppSettings
from sqlalchemy import text
from app_new.extensions import db
from app_new.core.models.maintenance import WatchdogLog
@ -34,7 +36,8 @@ def index():
return render_template('logs/index.html',
pagination=pagination,
watchdogs=watchdogs,
filters={'level': level, 'watchdog': watchdog})
filters={'level': level, 'watchdog': watchdog},
retention_days=int(AppSettings.get('watchdog_log_retention_days', '90')))
@logs_bp.route('/api/recent')
@ -69,3 +72,15 @@ def clear_watchdog_logs():
db.session.commit()
flash('Les journaux des watchdogs ont été vidés.', 'success')
return redirect(url_for('logs.index'))
@logs_bp.route('/retention', methods=['POST'])
@system_admin_required
def apply_retention():
days = max(1, min(request.form.get('days', type=int) or 90, 3650))
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
deleted = WatchdogLog.query.filter(WatchdogLog.created_at < cutoff).delete(synchronize_session=False)
db.session.commit()
AppSettings.set('watchdog_log_retention_days', str(days), 'Durée de conservation des logs watchdog')
flash(f'Rétention fixée à {days} jours ; {deleted} ancien(s) journal(aux) supprimé(s).', 'success')
return redirect(url_for('logs.index'))

View file

@ -7,6 +7,7 @@
<div class="d-flex gap-2">
<a href="{{ url_for('logs.api_recent', limit=100) }}" class="btn btn-sm btn-outline-secondary" target="_blank"><i class="bi bi-download"></i> API JSON</a>
{% if current_user.is_admin() %}
<form method="POST" action="{{ url_for('logs.apply_retention') }}" class="d-flex"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="number" min="1" max="3650" name="days" value="{{ retention_days }}" class="form-control form-control-sm" style="width:85px" title="Jours de conservation"><button class="btn btn-sm btn-outline-primary">Purger</button></form>
<form method="POST" action="{{ url_for('logs.clear_watchdog_logs') }}" onsubmit="return confirm('Vider définitivement tous les journaux des watchdogs ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">

View file

@ -0,0 +1,26 @@
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from app_new.extensions import db
from app_new.core.models.settings import AppSettings
from app_new.core.models.maintenance import WatchdogLog
from app_new.logs.routes import apply_retention
def test_encrypted_application_setting_is_not_stored_in_plain_text(app):
with app.app_context():
key = f"secret-{uuid4().hex}"
AppSettings.set("test_encrypted_secret", key, is_encrypted=True)
row = AppSettings.query.filter_by(key="test_encrypted_secret").one()
assert row.value.startswith("enc:") and key not in row.value
assert AppSettings.get("test_encrypted_secret") == key
def test_watchdog_log_retention_removes_only_expired_rows(app):
with app.app_context():
old = WatchdogLog(watchdog_name="test", level="info", message="old", created_at=datetime.now(timezone.utc)-timedelta(days=100))
recent = WatchdogLog(watchdog_name="test", level="info", message="recent", created_at=datetime.now(timezone.utc))
db.session.add_all([old, recent]); db.session.commit(); old_id, recent_id = old.id, recent.id
with app.test_request_context(method="POST", data={"days":"30"}):
assert apply_retention.__wrapped__().status_code == 302
assert db.session.get(WatchdogLog, old_id) is None
assert db.session.get(WatchdogLog, recent_id) is not None