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 VERSION /app/VERSION
|
||||||
COPY --chown=1000:1000 wsgi.py run_app_new.py /app/
|
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 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/
|
COPY --chown=1000:1000 docker/backup.py docker/backup_loop.py /app/
|
||||||
|
|
||||||
# Tracabilite du build (surchargeable avec --build-arg GIT_VERSION=...)
|
# Tracabilite du build (surchargeable avec --build-arg GIT_VERSION=...)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ def index():
|
||||||
has_key=bool(api_key),
|
has_key=bool(api_key),
|
||||||
model=model,
|
model=model,
|
||||||
model_status=model_status,
|
model_status=model_status,
|
||||||
model_error=model_error)
|
model_error=model_error,
|
||||||
|
human_approval=True)
|
||||||
|
|
||||||
|
|
||||||
@ai_config_bp.route('/save', methods=['POST'])
|
@ai_config_bp.route('/save', methods=['POST'])
|
||||||
|
|
@ -68,7 +69,7 @@ def save():
|
||||||
"""Sauvegarde la configuration IA."""
|
"""Sauvegarde la configuration IA."""
|
||||||
# Cle API
|
# Cle API
|
||||||
api_key = request.form.get('api_key', '').strip()
|
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,
|
AppSettings.set('openrouter_api_key', api_key,
|
||||||
description='Cle API OpenRouter pour l\'interpretation des emails',
|
description='Cle API OpenRouter pour l\'interpretation des emails',
|
||||||
is_encrypted=True)
|
is_encrypted=True)
|
||||||
|
|
@ -78,6 +79,8 @@ def save():
|
||||||
if model:
|
if model:
|
||||||
AppSettings.set('openrouter_model', model,
|
AppSettings.set('openrouter_model', model,
|
||||||
description='Modele OpenRouter pour l\'interpretation IA')
|
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')
|
flash('Configuration IA sauvegardee.', 'success')
|
||||||
return redirect(url_for('ai_config.index'))
|
return redirect(url_for('ai_config.index'))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h2 class="h4 mb-3"><i class="bi bi-robot"></i> Configuration IA (OpenRouter)</h2>
|
<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="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ Modèles pour les paramètres de l'application
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app_new.extensions import db
|
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):
|
class AppSettings(db.Model):
|
||||||
|
|
@ -22,22 +28,37 @@ class AppSettings(db.Model):
|
||||||
"""Récupère un paramètre par sa clé."""
|
"""Récupère un paramètre par sa clé."""
|
||||||
setting = AppSettings.query.filter_by(key=key).first()
|
setting = AppSettings.query.filter_by(key=key).first()
|
||||||
if setting:
|
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 setting.value
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set(key, value, description=None, is_encrypted=False):
|
def set(key, value, description=None, is_encrypted=False):
|
||||||
"""Définit un paramètre."""
|
"""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()
|
setting = AppSettings.query.filter_by(key=key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = value
|
setting.value = stored_value
|
||||||
|
setting.is_encrypted = is_encrypted
|
||||||
setting.updated_at = datetime.now(timezone.utc)
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
if description:
|
if description:
|
||||||
setting.description = description
|
setting.description = description
|
||||||
else:
|
else:
|
||||||
setting = AppSettings(
|
setting = AppSettings(
|
||||||
key=key,
|
key=key,
|
||||||
value=value,
|
value=stored_value,
|
||||||
description=description,
|
description=description,
|
||||||
is_encrypted=is_encrypted
|
is_encrypted=is_encrypted
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def index():
|
||||||
outlook_accounts = OutlookAccount.query.filter_by(user_id=current_user.id).all()
|
outlook_accounts = OutlookAccount.query.filter_by(user_id=current_user.id).all()
|
||||||
|
|
||||||
# Recuperer la cle API et le modele OpenRouter
|
# 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')
|
openrouter_model = AppSettings.get('openrouter_model', 'poolside/laguna-s-2.1:free')
|
||||||
ollama_model = AppSettings.get('ollama_model', 'gemma3:4b-it')
|
ollama_model = AppSettings.get('ollama_model', 'gemma3:4b-it')
|
||||||
|
|
||||||
|
|
@ -80,8 +80,8 @@ def save():
|
||||||
# Configuration IA
|
# Configuration IA
|
||||||
from app_new.core.models.settings import AppSettings
|
from app_new.core.models.settings import AppSettings
|
||||||
api_key = request.form.get('openrouter_api_key', '').strip()
|
api_key = request.form.get('openrouter_api_key', '').strip()
|
||||||
if api_key:
|
if api_key and api_key != '••••••••':
|
||||||
AppSettings.set('openrouter_api_key', api_key)
|
AppSettings.set('openrouter_api_key', api_key, is_encrypted=True)
|
||||||
model = request.form.get('openrouter_model', '').strip()
|
model = request.form.get('openrouter_model', '').strip()
|
||||||
if model:
|
if model:
|
||||||
AppSettings.set('openrouter_model', model)
|
AppSettings.set('openrouter_model', model)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Logs centralises des watchdogs."""
|
"""Logs centralises des watchdogs."""
|
||||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
|
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
|
||||||
from flask_login import login_required
|
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 sqlalchemy import text
|
||||||
from app_new.extensions import db
|
from app_new.extensions import db
|
||||||
from app_new.core.models.maintenance import WatchdogLog
|
from app_new.core.models.maintenance import WatchdogLog
|
||||||
|
|
@ -34,7 +36,8 @@ def index():
|
||||||
return render_template('logs/index.html',
|
return render_template('logs/index.html',
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
watchdogs=watchdogs,
|
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')
|
@logs_bp.route('/api/recent')
|
||||||
|
|
@ -69,3 +72,15 @@ def clear_watchdog_logs():
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Les journaux des watchdogs ont été vidés.', 'success')
|
flash('Les journaux des watchdogs ont été vidés.', 'success')
|
||||||
return redirect(url_for('logs.index'))
|
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">
|
<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>
|
<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() %}
|
{% 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 ?');">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
<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