feat(M2): configuration, gestion des secrets et logging
- config/settings.py : modèles Pydantic Settings (Pronote, CalDAV, XMPP, AI, Blog, App) avec SecretStr pour les mots de passe et clés API - config/env.py : fonction load_settings() pour le chargement du .env - utils/redaction.py : redact_url, redact_secrets, redact_exception (masquage des tokens icalsecurise, mots de passe et URLs sensibles) - utils/logging.py : setup_logging + RedactingFormatter (masquage automatique des secrets dans les logs) - utils/uid.py : normalize_pronote_uid (suppression suffixes temporels) et generate_deterministic_uid (hash SHA-1 usedforsecurity=False) - .env.example : aligné sur les modèles finaux (XMPP_TO, XMPP_HOST, XMPP_ENABLED, BLOG_ENABLED, etc.) - .pre-commit-config.yaml : ajout pydantic + pydantic-settings aux additional_dependencies du hook mypy - TODO.md : items M2 cochés Décisions d'architecture (@architect) : - XmppSettings : modèle complet §10.2.3, tous champs optionnels - sync_past_days/future_days déplacés vers AppSettings (sans préfixe) - AISettings.enabled = False par défaut - BlogSettings inclus dès M2 - redact_exception comme fonction module (pas méthode) - normalize_pronote_uid (nom du guide et des tests) Validations : - ruff check : PASS - ruff format --check : PASS - mypy strict : PASS (7 fichiers) - bandit : PASS (0 issue) - import settings : OK (toutes valeurs par défaut) - redact_secrets/icalsecurise : masqué en REDACTED - logging : secret masqué dans la sortie - uid normalize : idempotent, deterministic OK - SecretStr : pas de fuite dans repr - pytest : 0 test (infrastructure OK) Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
This commit is contained in:
12
.env.example
12
.env.example
@@ -22,9 +22,15 @@ SYNC_FUTURE_DAYS=30
|
||||
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
||||
|
||||
# --- XMPP ---
|
||||
XMPP_ENABLED=false
|
||||
XMPP_JID=user@example.com
|
||||
XMPP_PASSWORD=your_xmpp_password
|
||||
XMPP_RECIPIENT=parent@example.com
|
||||
XMPP_HOST=example.com
|
||||
XMPP_PORT=5222
|
||||
XMPP_TO=parent@example.com
|
||||
XMPP_RESOURCE=pronote-sync
|
||||
XMPP_USE_TLS=true
|
||||
XMPP_TIMEOUT=30
|
||||
|
||||
# --- IA (optionnelle) ---
|
||||
AI_ENABLED=true
|
||||
@@ -32,6 +38,10 @@ AI_BASE_URL=https://api.openai.com/v1
|
||||
AI_API_KEY=your_ai_api_key
|
||||
AI_MODEL=gpt-4o-mini
|
||||
|
||||
# --- Blog ---
|
||||
BLOG_ENABLED=false
|
||||
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
||||
|
||||
# --- Divers ---
|
||||
DRY_RUN=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -26,7 +26,7 @@ repos:
|
||||
name: mypy
|
||||
entry: mypy
|
||||
language: python
|
||||
additional_dependencies: ["mypy>=1.10.0"]
|
||||
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"]
|
||||
types: [python]
|
||||
pass_filenames: true
|
||||
|
||||
|
||||
14
TODO.md
14
TODO.md
@@ -31,13 +31,13 @@ Mettre en place le dépôt, l'environnement, l'arborescence du package et la cha
|
||||
|
||||
Implémenter la configuration Pydantic Settings, le masquage des secrets et la journalisation sûre.
|
||||
|
||||
- [ ] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env.
|
||||
- [ ] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`).
|
||||
- [ ] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2).
|
||||
- [ ] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1).
|
||||
- [ ] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2).
|
||||
- [ ] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote).
|
||||
- [ ] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
|
||||
- [x] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env.
|
||||
- [x] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`).
|
||||
- [x] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2).
|
||||
- [x] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1).
|
||||
- [x] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2).
|
||||
- [x] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote).
|
||||
- [x] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
|
||||
|
||||
### Critères d'acceptation
|
||||
- `from pronote_sync.config.settings import settings` fonctionne et charge `.env`.
|
||||
|
||||
13
pronote_sync/config/env.py
Normal file
13
pronote_sync/config/env.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Chargement de la configuration depuis l'environnement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pronote_sync.config.settings import Settings
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""Charge et valide la configuration depuis le fichier ``.env`` et les variables d'environnement.
|
||||
|
||||
:return: Instance de configuration validée.
|
||||
"""
|
||||
return Settings()
|
||||
132
pronote_sync/config/settings.py
Normal file
132
pronote_sync/config/settings.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Configuration de l'application via ``pydantic-settings``.
|
||||
|
||||
Chaque sous-groupe de configuration est un modèle ``BaseSettings`` dédié, chargé
|
||||
depuis les variables d'environnement (préfixées par groupe) et le fichier
|
||||
``.env``. Tous les champs disposent de valeurs par défaut sûres afin que
|
||||
``Settings()`` fonctionne même sans fichier de configuration présent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class PronoteSettings(BaseSettings):
|
||||
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
|
||||
|
||||
Les variables d'environnement correspondantes sont préfixées par
|
||||
``PRONOTE_``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
||||
|
||||
ical_url: str | None = None
|
||||
username: str | None = None
|
||||
password: SecretStr | None = None
|
||||
ent: str | None = None
|
||||
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||
messages_source: Literal["pronotepy"] = "pronotepy"
|
||||
|
||||
|
||||
class CalDAVSettings(BaseSettings):
|
||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||
|
||||
Les variables d'environnement correspondantes sont préfixées par
|
||||
``CALDAV_``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_")
|
||||
|
||||
url: str | None = None
|
||||
username: str | None = None
|
||||
password: SecretStr | None = None
|
||||
calendar_path: str = "/pronote-sync/"
|
||||
|
||||
|
||||
class XmppSettings(BaseSettings):
|
||||
"""Paramètres du canal de notifications XMPP (désactivé par défaut).
|
||||
|
||||
Tous les champs ont des valeurs par défaut afin que le canal XMPP reste
|
||||
inactif tant qu'il n'est pas explicitement activé. Les variables
|
||||
d'environnement correspondantes sont préfixées par ``XMPP_``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="XMPP_")
|
||||
|
||||
enabled: bool = False
|
||||
jid: str | None = None
|
||||
password: SecretStr | None = None
|
||||
host: str = ""
|
||||
port: int = 5222
|
||||
to: str | None = None
|
||||
resource: str = "pronote-sync"
|
||||
use_tls: bool = True
|
||||
timeout: int = 30
|
||||
|
||||
|
||||
class AISettings(BaseSettings):
|
||||
"""Paramètres de la synthèse par IA (désactivée par défaut).
|
||||
|
||||
Les variables d'environnement correspondantes sont préfixées par ``AI_``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["openai", "litellm"] = "openai"
|
||||
base_url: str | None = None
|
||||
api_key: SecretStr | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class BlogSettings(BaseSettings):
|
||||
"""Paramètres de la source RSS du blog du collège (désactivée par défaut).
|
||||
|
||||
Les variables d'environnement correspondantes sont préfixées par
|
||||
``BLOG_``.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_")
|
||||
|
||||
enabled: bool = False
|
||||
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2"
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
"""Paramètres généraux de l'application, sans préfixe d'environnement.
|
||||
|
||||
Contient notamment la fenêtre de synchronisation en jours
|
||||
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``).
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
dry_run: bool = False
|
||||
log_level: str = "INFO"
|
||||
theoretical_agenda_path: str | None = None
|
||||
sync_past_days: int = 7
|
||||
sync_future_days: int = 30
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration racine du pipeline ``pronote-sync``.
|
||||
|
||||
Agrège les sous-groupes de configuration : Pronote, CalDAV, XMPP, IA,
|
||||
blog et paramètres généraux de l'application.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
pronote: PronoteSettings = PronoteSettings()
|
||||
caldav: CalDAVSettings = CalDAVSettings()
|
||||
xmpp: XmppSettings = XmppSettings()
|
||||
ai: AISettings = AISettings()
|
||||
blog: BlogSettings = BlogSettings()
|
||||
app: AppSettings = AppSettings()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
67
pronote_sync/utils/logging.py
Normal file
67
pronote_sync/utils/logging.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Configuration de la journalisation avec masquage automatique des secrets.
|
||||
|
||||
Ce module fournit un formateur de logs qui rédige les secrets (tokens, mots
|
||||
de passe, URLs sensibles) ainsi que l'initialisation du logging global du
|
||||
pipeline ``pronote-sync``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pronote_sync.utils.redaction import redact_secrets
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""Formateur de logs qui masque les secrets des messages et des arguments."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Formate un enregistrement de log en masquant les secrets.
|
||||
|
||||
Le message et chaque argument textuel de l'enregistrement sont rédigés
|
||||
avant le formatage final effectué par :class:`logging.Formatter`.
|
||||
|
||||
:param record: Enregistrement de log à formater.
|
||||
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
|
||||
:rtype: str
|
||||
"""
|
||||
record.msg = redact_secrets(str(record.msg))
|
||||
args = record.args
|
||||
if args:
|
||||
if isinstance(args, tuple):
|
||||
record.args = tuple(
|
||||
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
|
||||
)
|
||||
else:
|
||||
record.args = {
|
||||
key: redact_secrets(value) if isinstance(value, str) else value
|
||||
for key, value in args.items()
|
||||
}
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""Configure la journalisation globale avec masquage des secrets.
|
||||
|
||||
Les gestionnaires existants du logger racine sont supprimés, puis un
|
||||
gestionnaire unique écrivant sur ``sys.stdout`` est installé. Les loggers
|
||||
tiers ``urllib3`` et ``slixmpp`` sont ramenés au niveau ``WARNING``.
|
||||
|
||||
:param level: Nom du niveau de log (ex: ``"DEBUG"``, ``"INFO"``) ;
|
||||
les noms inconnus sont ignorés au profit de ``INFO``.
|
||||
:rtype: None
|
||||
"""
|
||||
numeric_level: int = logging.getLevelNamesMapping().get(level.upper(), logging.INFO)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(RedactingFormatter(fmt=_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
||||
root = logging.getLogger()
|
||||
for existing in list(root.handlers):
|
||||
root.removeHandler(existing)
|
||||
root.setLevel(numeric_level)
|
||||
root.addHandler(handler)
|
||||
for noisy_name in ("urllib3", "slixmpp"):
|
||||
logging.getLogger(noisy_name).setLevel(logging.WARNING)
|
||||
65
pronote_sync/utils/redaction.py
Normal file
65
pronote_sync/utils/redaction.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Utilitaires de masquage des secrets dans les URLs, textes et exceptions.
|
||||
|
||||
Ce module centralise la rédaction des données sensibles (tokens, mots de
|
||||
passe, clés d'accès) afin qu'aucun secret ne soit exposé dans les logs,
|
||||
les messages d'erreur ou les traces du pipeline ``pronote-sync``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"})
|
||||
_URL_PATTERN = re.compile(r"https?://[^\s]+")
|
||||
_ISOLATED_SECRET_PATTERN = re.compile(
|
||||
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REDACTED = "REDACTED"
|
||||
_REDACTED_URL = "REDACTED_URL"
|
||||
|
||||
|
||||
def redact_url(url: str) -> str:
|
||||
"""Masque les paramètres sensibles dans une URL.
|
||||
|
||||
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``).
|
||||
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``,
|
||||
ou ``REDACTED_URL`` si le traitement échoue.
|
||||
:rtype: str
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
||||
redacted_query = [
|
||||
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
|
||||
for key, value in query
|
||||
]
|
||||
return urlunsplit(parts._replace(query=urlencode(redacted_query, doseq=True)))
|
||||
except Exception:
|
||||
return _REDACTED_URL
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> str:
|
||||
"""Masque les secrets présents dans un texte arbitraire.
|
||||
|
||||
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
|
||||
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées,
|
||||
sans distinction de casse.
|
||||
|
||||
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
||||
:rtype: str
|
||||
"""
|
||||
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted)
|
||||
|
||||
|
||||
def redact_exception(exc: Exception) -> str:
|
||||
"""Masque les secrets dans la représentation textuelle d'une exception.
|
||||
|
||||
:param exc: Exception dont le message doit être rédigé.
|
||||
:return: Représentation textuelle de l'exception avec les secrets masqués.
|
||||
:rtype: str
|
||||
"""
|
||||
return redact_secrets(str(exc))
|
||||
67
pronote_sync/utils/uid.py
Normal file
67
pronote_sync/utils/uid.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Utilitaires de gestion des identifiants uniques (UID) des événements.
|
||||
|
||||
Ce module fournit la normalisation des UIDs Pronote (suppression des
|
||||
suffixes temporels) et la génération d'UIDs déterministes par hachage
|
||||
des champs clés d'un événement, garantissant l'idempotence de la
|
||||
synchronisation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
|
||||
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
|
||||
|
||||
|
||||
def normalize_pronote_uid(uid: str) -> str:
|
||||
"""Normalise un UID Pronote en supprimant ses suffixes temporels.
|
||||
|
||||
Les suffixes de type ``-AAAAMMJJTHHMMSSZ-Index-Education`` puis
|
||||
``-Index-Education`` sont retirés. La fonction est idempotente :
|
||||
appliquée à un UID déjà normalisé, elle retourne la même valeur.
|
||||
|
||||
:param uid: UID brut provenant de Pronote (ex: ``L-1234-20250901T080000Z-Index-Education``).
|
||||
:return: UID normalisé, sans suffixe temporel ni marque ``Index-Education``.
|
||||
:rtype: str
|
||||
"""
|
||||
normalized = _TEMPORAL_SUFFIX_PATTERN.sub("", uid)
|
||||
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
|
||||
|
||||
|
||||
def generate_deterministic_uid(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
subject: str,
|
||||
teachers: list[str],
|
||||
rooms: list[str],
|
||||
group: str | None = None,
|
||||
) -> str:
|
||||
"""Génère un UID déterministe par hachage des champs clés d'un événement.
|
||||
|
||||
Utilisé lorsqu'aucun UID exploitable n'est disponible : deux appels avec
|
||||
des champs identiques produisent le même identifiant, ce qui garantit
|
||||
l'idempotence de la synchronisation.
|
||||
|
||||
:param start: Début de l'événement.
|
||||
:param end: Fin de l'événement.
|
||||
:param subject: Intitulé de la matière.
|
||||
:param teachers: Liste des enseignants (triée avant hachage).
|
||||
:param rooms: Liste des salles (triée avant hachage).
|
||||
:param group: Groupe éventuel ; traité comme chaîne vide si absent.
|
||||
:return: Identifiant déterministe : 12 premiers caractères hexadécimaux
|
||||
du SHA-1 des champs clés joints par ``|``.
|
||||
:rtype: str
|
||||
"""
|
||||
parts = [
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
subject,
|
||||
",".join(sorted(teachers)),
|
||||
",".join(sorted(rooms)),
|
||||
group or "",
|
||||
]
|
||||
payload = "|".join(parts).encode("utf-8")
|
||||
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
|
||||
Reference in New Issue
Block a user