Sécurise et supervise les intégrations externes
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
ed6675b9d3
commit
8f276b5c47
8 changed files with 80 additions and 12 deletions
|
|
@ -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=...)
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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> l’IA 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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
tests/integration/test_integration_security.py
Normal file
26
tests/integration/test_integration_security.py
Normal 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
|
||||
Loading…
Reference in a new issue