Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66436629af | ||
|
|
af00ab36cc | ||
|
|
8563e6daac | ||
|
|
51d041c0da |
@@ -0,0 +1,44 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
IMAGE: git.nfteam.ovh/neckfire/datasentinel-api
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" .
|
||||
|
||||
- name: Tests (pytest dans l'image, BDD simulée)
|
||||
run: |
|
||||
docker run --rm "${IMAGE}:${GITHUB_SHA::12}" \
|
||||
sh -c 'pip install -q pytest httpx && pytest -q'
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
set -e
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker push --all-tags "${IMAGE}"
|
||||
echo "pushed ${IMAGE}"
|
||||
|
||||
- name: Notify ntfy (success/failure)
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
|
||||
curl -s \
|
||||
-H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" \
|
||||
-H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
|
||||
-H "Priority: ${PRIO}" \
|
||||
-H "Tags: ${EMOJI}" \
|
||||
-H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
|
||||
-d "${GITHUB_WORKFLOW} sur ${GITHUB_REF_NAME} (run #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
|
||||
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
Format : [Keep a Changelog](https://keepachangelog.com/fr/) · Versioning sémantique.
|
||||
|
||||
## [1.1.0] — 2026-06-20
|
||||
|
||||
### Ajouté
|
||||
- Authentification **JWT** (`/auth/login`, `/auth/me`) + hachage bcrypt (`auth.py`).
|
||||
- **RBAC** : protection de tous les endpoints de données (401 sans token), routes `/admin/*`
|
||||
réservées au rôle `Admin` (403 sinon).
|
||||
- Endpoints d'administration : CRUD utilisateurs (`/admin/users`), reset de mot de passe,
|
||||
journal d'audit (`/admin/journal`).
|
||||
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
|
||||
- En-têtes de sécurité HTTP + rate-limit `5/min` sur le login (`slowapi`).
|
||||
- Connexion DB et CORS configurables par variables d'environnement (auth SQL, Driver 18).
|
||||
- `Dockerfile` + CI Gitea Actions (build → tests pytest → push image) + notifications ntfy.
|
||||
- Tests `pytest` (auth, RBAC, endpoints) avec curseur SQL simulé.
|
||||
- Tables `[USER]` + `JOURNAL_AUDIT` et comptes de démo.
|
||||
|
||||
### Modifié
|
||||
- CORS : tous les verbes + credentials (au lieu de `GET` seul).
|
||||
|
||||
### Sécurité
|
||||
- Plus aucun secret en dur côté déploiement (secrets via env / fichiers hors dépôt).
|
||||
- La base s'exécute sur un SQL Server partagé ; compte applicatif non-`sa`.
|
||||
|
||||
## [1.0.0] — 2026-04
|
||||
- Version initiale (A. Coyaud) : API de lecture sur SQL Server (référentiels, monitorings,
|
||||
dashboard, historique, évolution).
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# API Data Sentinel — FastAPI + pyodbc (ODBC Driver 18 pour SQL Server).
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Pilote Microsoft ODBC 18 (le 17 n'est plus packagé sur Debian 12 / bookworm).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl gnupg ca-certificates apt-transport-https \
|
||||
&& curl -sSL https://packages.microsoft.com/keys/microsoft.asc \
|
||||
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||
&& echo "deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \
|
||||
> /etc/apt/sources.list.d/mssql-release.list \
|
||||
&& apt-get update \
|
||||
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,34 +1,85 @@
|
||||
# Api-DataSentinel
|
||||
|
||||
API FastAPI pour Data Sentinel.
|
||||
API **FastAPI** de monitoring de la qualité des données — Data Sentinel (XEFI).
|
||||
Backend en lecture seule sur SQL Server, sécurisé par authentification JWT.
|
||||
|
||||
## Installation
|
||||
## Stack
|
||||
|
||||
- Python 3.12 · FastAPI 0.136 · uvicorn
|
||||
- SQL Server 2022 via `pyodbc` (ODBC Driver 18)
|
||||
- Auth : JWT (`python-jose`) + bcrypt (`passlib`), rate-limit (`slowapi`)
|
||||
|
||||
## Lancement en local (dev)
|
||||
|
||||
1. Ouvrir un terminal dans le dossier du projet :
|
||||
```powershell
|
||||
cd "c:\Users\antho\Desktop\Projet de fin d'année\Projet fin d_année\DataSentinel\Api-DataSentinel"
|
||||
```
|
||||
2. Créer et activer l'environnement virtuel (si nécessaire) :
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
```
|
||||
3. Installer les dépendances :
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
python -m uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
## Lancement de l'API
|
||||
- Swagger : `http://127.0.0.1:8000/docs` · Santé : `http://127.0.0.1:8000/health`
|
||||
|
||||
Utiliser la commande suivante pour démarrer le serveur :
|
||||
Sans variables d'environnement, l'API se connecte en authentification Windows
|
||||
(`LaptopCA\SQLEXPRESS`) — comportement de dev d'origine, inchangé.
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m uvicorn main:app --reload --port 8000
|
||||
## Configuration (variables d'environnement)
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
|----------|------|--------|
|
||||
| `DB_SERVER` | hôte SQL Server (active l'auth SQL si défini) | — (sinon Windows local) |
|
||||
| `DB_PORT` / `DB_NAME` | port / base | `1433` / `DataSentinel` |
|
||||
| `DB_USER` / `DB_PASSWORD` | compte applicatif | — |
|
||||
| `DB_DRIVER` | pilote ODBC | `ODBC Driver 18 for SQL Server` |
|
||||
| `CORS_ORIGINS` | origines autorisées (séparées par `,`) | `localhost:5173,localhost:3000` |
|
||||
| `JWT_SECRET` | clé de signature JWT | placeholder (à définir en prod) |
|
||||
| `JWT_ALGORITHM` / `JWT_EXPIRE_MINUTES` | algo / durée du token | `HS256` / `60` |
|
||||
|
||||
## Authentification & rôles
|
||||
|
||||
- `POST /auth/login` (form `username`/`password`) → `{access_token, token_type, user}`. Rate-limité **5/min**.
|
||||
- `GET /auth/me` → profil courant.
|
||||
- **Tous les endpoints de données exigent** `Authorization: Bearer <token>` (401 sinon). Seul `/health` est public.
|
||||
- Rôles : `Admin`, `Superviseur`, `Consultant`. Les routes `/admin/*` exigent `Admin` (403 sinon).
|
||||
|
||||
### Comptes de démo
|
||||
|
||||
| Identifiant | Mot de passe | Rôle |
|
||||
|-------------|--------------|------|
|
||||
| `admin` | `Admin2026!` | Admin |
|
||||
| `superviseur` | `Super2026!` | Superviseur |
|
||||
| `consultant` | `Conseil2026!` | Consultant |
|
||||
|
||||
## Endpoints (résumé)
|
||||
|
||||
- **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`,
|
||||
`/dashboard[...]`, `/historique[...]`, `/evolution/*`.
|
||||
- **Admin** (`Admin`) : `GET/POST /admin/users`, `PUT/DELETE /admin/users/{id}`,
|
||||
`POST /admin/users/{id}/reset-password`, `GET /admin/journal`.
|
||||
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
|
||||
|
||||
Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`).
|
||||
|
||||
## Sécurité
|
||||
|
||||
- En-têtes : `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Strict-Transport-Security`.
|
||||
- CORS restreint aux origines `CORS_ORIGINS`, tous verbes + credentials.
|
||||
- Requêtes SQL **paramétrées** (noms de tables/colonnes whitelistés) ; le compte applicatif
|
||||
n'est pas `sa`. Tables d'auth : `[USER]` + `JOURNAL_AUDIT` (`sql/data_sentinel_auth.sql`).
|
||||
- Journal d'audit alimenté à chaque login + action admin.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install pytest httpx && pytest -q
|
||||
```
|
||||
Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401, RBAC 403, 404.
|
||||
|
||||
> Important : si le lanceur `\.venv\Scripts\uvicorn.exe` est cassé après un déplacement de dossier, utilisez toujours `python -m uvicorn`.
|
||||
## Docker & CI/CD
|
||||
|
||||
## Vérification
|
||||
|
||||
- Swagger : `http://127.0.0.1:8000/docs`
|
||||
- Santé : `http://127.0.0.1:8000/health`
|
||||
- `Dockerfile` : image Python 3.12 + `msodbcsql18`.
|
||||
- CI Gitea Actions (`.gitea/workflows/build.yml`) : **build → pytest (dans l'image) → push**
|
||||
vers `git.nfteam.ovh/neckfire/datasentinel-api` + notification ntfy. Déclenché sur push `main`.
|
||||
- Déploiement, exploitation, sauvegarde : voir `RUNBOOK.md` et le stack d'hébergement
|
||||
(`homelab/dev/datasentinel/`). La base tourne sur un **SQL Server partagé** (`dev-mssql`),
|
||||
pas dédiée à l'app.
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# RUNBOOK — Data Sentinel (déploiement & exploitation)
|
||||
|
||||
Hébergement sur le homelab `nfteam.ovh`. Stack d'exécution :
|
||||
`~/Documents/homelab/dev/datasentinel/` (API + Front) et `~/Documents/homelab/dev/mssql/`
|
||||
(SQL Server partagé `dev-mssql`, réseau externe `dev-shared`).
|
||||
|
||||
## Architecture
|
||||
|
||||
| Composant | URL publique | Port interne | Conteneur |
|
||||
|-----------|--------------|--------------|-----------|
|
||||
| Front (React) | https://datasentinel.nfteam.ovh | 8086 | `datasentinel-front` |
|
||||
| API (FastAPI) | https://datasentinel-api.nfteam.ovh | 8001 | `datasentinel-api` |
|
||||
| Base | — (réseau `dev-shared`) | 1433 | `dev-mssql` (partagé) |
|
||||
|
||||
Reverse proxy : Nginx Proxy Manager (HTTPS Let's Encrypt). CI : Gitea Actions →
|
||||
images poussées au registre `git.nfteam.ovh`, déployées par `docker compose` / Watchtower.
|
||||
|
||||
## Déploiement / mise à jour
|
||||
|
||||
Un push sur `main` (API ou Front) déclenche la CI (build → tests → push image).
|
||||
Récupérer la dernière image et redéployer :
|
||||
```bash
|
||||
cd ~/Documents/homelab/dev/datasentinel
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
Watchtower met aussi à jour automatiquement (~5 min).
|
||||
|
||||
## Diagnostic incidents
|
||||
|
||||
| Symptôme | Diagnostic | Résolution |
|
||||
|----------|-----------|------------|
|
||||
| Front KO | `curl -I https://datasentinel.nfteam.ovh` ; `docker logs datasentinel-front` | `docker compose restart datasentinel-front` |
|
||||
| API 5xx | `curl https://datasentinel-api.nfteam.ovh/health` → champ `database` | si `error` → voir BDD ci-dessous ; `docker logs datasentinel-api` |
|
||||
| BDD injoignable | `docker ps | grep dev-mssql` ; tester la connexion (cf. ci-dessous) | `docker compose -f ~/Documents/homelab/dev/mssql/docker-compose.yml up -d` |
|
||||
| Token invalide / 401 partout | vérifier `JWT_SECRET` dans `api.env` (ne pas le changer à chaud : invalide les sessions) | re-login |
|
||||
| Login 429 | rate-limit 5/min atteint | attendre 1 min |
|
||||
|
||||
Connexion BDD (admin) :
|
||||
```bash
|
||||
SA=$(grep ^SA_PASSWORD= ~/Documents/homelab/dev/mssql/.env | cut -d= -f2-)
|
||||
docker run --rm --network dev-shared mcr.microsoft.com/mssql-tools \
|
||||
/opt/mssql-tools/bin/sqlcmd -S dev-mssql -U sa -P "$SA" -d DataSentinel -Q "SELECT COUNT(*) FROM [USER];"
|
||||
```
|
||||
|
||||
## (Re)chargement du schéma
|
||||
|
||||
```bash
|
||||
SA=$(grep ^SA_PASSWORD= ~/Documents/homelab/dev/mssql/.env | cut -d= -f2-)
|
||||
for f in data_sentinel_init.sql data_sentinel_auth.sql; do
|
||||
docker run --rm --network dev-shared \
|
||||
-v ~/Documents/homelab/dev/datasentinel/sql:/sql:ro mcr.microsoft.com/mssql-tools \
|
||||
/opt/mssql-tools/bin/sqlcmd -S dev-mssql -U sa -P "$SA" -i /sql/$f
|
||||
done
|
||||
```
|
||||
|
||||
## Sauvegarde / restauration
|
||||
|
||||
> ⚠️ Stratégie à finaliser avec le disque de sauvegarde dédié du homelab.
|
||||
|
||||
Sauvegarde logique recommandée (quotidienne) :
|
||||
```bash
|
||||
SA=$(grep ^SA_PASSWORD= ~/Documents/homelab/dev/mssql/.env | cut -d= -f2-)
|
||||
docker exec dev-mssql /opt/mssql-tools*/bin/sqlcmd -S localhost -U sa -P "$SA" \
|
||||
-Q "BACKUP DATABASE DataSentinel TO DISK='/var/opt/mssql/backup/DataSentinel.bak' WITH FORMAT, INIT, COMPRESSION"
|
||||
```
|
||||
(monter un volume `/var/opt/mssql/backup` vers le disque de sauvegarde). Le volume
|
||||
Docker `mssql_data` contient les fichiers de la base. RTO visé < 4h, RPO < 24h.
|
||||
|
||||
## Rollback
|
||||
|
||||
Redéployer une image précise par son tag SHA (au lieu de `latest`) :
|
||||
```bash
|
||||
# dans dev/datasentinel/docker-compose.yml : image: .../datasentinel-api:<sha>
|
||||
docker compose up -d datasentinel-api
|
||||
```
|
||||
|
||||
## Contacts
|
||||
|
||||
- Hébergement / infra : administrateur homelab (neckfire).
|
||||
- Application / code : A. Coyaud (auteur, dépôts GitHub).
|
||||
@@ -0,0 +1,54 @@
|
||||
# ============================================================
|
||||
# auth.py — Authentification JWT + hachage bcrypt
|
||||
# Data Sentinel
|
||||
# ============================================================
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from config import Config
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
payload = data.copy()
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=Config.TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
"""Décode le JWT et retourne l'utilisateur courant, sinon 401."""
|
||||
creds_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Identifiants invalides",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=[Config.ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise creds_exc
|
||||
return {"username": username, "role": payload.get("role"), "id_user": payload.get("uid")}
|
||||
except JWTError:
|
||||
raise creds_exc
|
||||
|
||||
|
||||
def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||
"""Réserve l'accès aux administrateurs (403 sinon)."""
|
||||
if user.get("role") != "Admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès réservé aux administrateurs")
|
||||
return user
|
||||
@@ -3,28 +3,50 @@
|
||||
# Data Sentinel | COYAUD Anthony | 2026
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import pyodbc
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class Config:
|
||||
# Chaîne de connexion SQL Server
|
||||
DB_CONNECTION_STRING = (
|
||||
def _build_connection_string() -> str:
|
||||
"""
|
||||
En déploiement (variables d'environnement présentes), on se connecte en
|
||||
authentification SQL. Sinon, on garde la connexion Windows locale par défaut
|
||||
(poste de dev).
|
||||
"""
|
||||
server = os.getenv("DB_SERVER")
|
||||
if server:
|
||||
driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
||||
port = os.getenv("DB_PORT", "1433")
|
||||
return (
|
||||
f"Driver={{{driver}}};"
|
||||
f"Server={server},{port};"
|
||||
f"Database={os.getenv('DB_NAME', 'DataSentinel')};"
|
||||
f"UID={os.getenv('DB_USER')};"
|
||||
f"PWD={os.getenv('DB_PASSWORD')};"
|
||||
"Encrypt=yes;TrustServerCertificate=yes;"
|
||||
)
|
||||
return (
|
||||
"Driver={ODBC Driver 17 for SQL Server};"
|
||||
"Server=LaptopCA\\SQLEXPRESS;"
|
||||
"Database=DataSentinel;"
|
||||
"Trusted_Connection=yes;"
|
||||
)
|
||||
|
||||
|
||||
class Config:
|
||||
# Chaîne de connexion SQL Server (env en prod, Windows en local)
|
||||
DB_CONNECTION_STRING = _build_connection_string()
|
||||
|
||||
# Paramètres API
|
||||
API_TITLE = "Data Sentinel API"
|
||||
API_VERSION = "1.0.0"
|
||||
API_DESCRIPTION = "API de monitoring de la qualité des données — XEFI"
|
||||
|
||||
# Sécurité JWT
|
||||
SECRET_KEY = "data-sentinel-secret-change-in-prod"
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_EXPIRE_MINUTES = 60
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "data-sentinel-secret-change-in-prod")
|
||||
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "60"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,12 +7,32 @@
|
||||
# Swagger : http://localhost:8000/docs
|
||||
# ============================================================
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, APIRouter, Depends, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from config import Config, get_cursor
|
||||
from auth import (
|
||||
verify_password, hash_password, create_access_token,
|
||||
get_current_user, require_admin,
|
||||
)
|
||||
|
||||
# Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod,
|
||||
# localhost par défaut en dev.
|
||||
CORS_ORIGINS = [
|
||||
o.strip()
|
||||
for o in os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
|
||||
if o.strip()
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# Initialisation
|
||||
@@ -24,13 +44,35 @@ app = FastAPI(
|
||||
description = Config.API_DESCRIPTION,
|
||||
)
|
||||
|
||||
# Limiteur de débit (anti brute-force sur /auth/login).
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins = ["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_methods = ["GET"],
|
||||
allow_origins = CORS_ORIGINS,
|
||||
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers = ["*"],
|
||||
allow_credentials = True,
|
||||
)
|
||||
|
||||
|
||||
# En-têtes de sécurité sur toutes les réponses.
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
|
||||
# Toutes les routes "données" passent par ce router, protégé par JWT.
|
||||
# /health et /auth/* restent publics (déclarés sur `app`).
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
# ============================================================
|
||||
# Mapping id_monito → table SQL dédiée
|
||||
# Pour ajouter un monitoring : ajouter une entrée ici.
|
||||
@@ -102,7 +144,7 @@ def serialize_row(row: dict) -> dict:
|
||||
# RÉFÉRENTIELS
|
||||
# ============================================================
|
||||
|
||||
@app.get("/categories", tags=["Référentiels"])
|
||||
@router.get("/categories", tags=["Référentiels"])
|
||||
def get_categories():
|
||||
"""Toutes les catégories de monitoring."""
|
||||
with get_cursor() as cursor:
|
||||
@@ -113,7 +155,7 @@ def get_categories():
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.get("/services", tags=["Référentiels"])
|
||||
@router.get("/services", tags=["Référentiels"])
|
||||
def get_services():
|
||||
"""Tous les services."""
|
||||
with get_cursor() as cursor:
|
||||
@@ -121,7 +163,7 @@ def get_services():
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.get("/contacts", tags=["Référentiels"])
|
||||
@router.get("/contacts", tags=["Référentiels"])
|
||||
def get_contacts(
|
||||
id_service: Optional[int] = Query(None, description="Filtrer par service")
|
||||
):
|
||||
@@ -145,7 +187,7 @@ def get_contacts(
|
||||
# NOMENCLATURE
|
||||
# ============================================================
|
||||
|
||||
@app.get("/monitorings", tags=["Monitorings"])
|
||||
@router.get("/monitorings", tags=["Monitorings"])
|
||||
def get_monitorings(
|
||||
id_service : Optional[int] = Query(None, description="Filtrer par service"),
|
||||
id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"),
|
||||
@@ -172,7 +214,7 @@ def get_monitorings(
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.get("/monitorings/{id_monito}", tags=["Monitorings"])
|
||||
@router.get("/monitorings/{id_monito}", tags=["Monitorings"])
|
||||
def get_monitoring_by_id(id_monito: int):
|
||||
"""Détail d'un monitoring."""
|
||||
with get_cursor() as cursor:
|
||||
@@ -192,7 +234,7 @@ def get_monitoring_by_id(id_monito: int):
|
||||
# DONNÉES DÉTAILLÉES — table dédiée par monitoring
|
||||
# ============================================================
|
||||
|
||||
@app.get("/monitorings/{id_monito}/details", tags=["Monitorings"])
|
||||
@router.get("/monitorings/{id_monito}/details", tags=["Monitorings"])
|
||||
def get_monitoring_details(
|
||||
id_monito : int,
|
||||
search : Optional[str] = Query(
|
||||
@@ -225,7 +267,7 @@ def get_monitoring_details(
|
||||
return [serialize_row(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/monitorings/{id_monito}/count", tags=["Monitorings"])
|
||||
@router.get("/monitorings/{id_monito}/count", tags=["Monitorings"])
|
||||
def get_monitoring_count(id_monito: int):
|
||||
"""Nombre d'erreurs dans la table dédiée du monitoring."""
|
||||
table = get_table_name(id_monito)
|
||||
@@ -238,7 +280,7 @@ def get_monitoring_count(id_monito: int):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/monitorings/{id_monito}/columns", tags=["Monitorings"])
|
||||
@router.get("/monitorings/{id_monito}/columns", tags=["Monitorings"])
|
||||
def get_monitoring_columns(id_monito: int):
|
||||
"""
|
||||
Retourne les colonnes de la table dédiée.
|
||||
@@ -263,7 +305,7 @@ def get_monitoring_columns(id_monito: int):
|
||||
# DASHBOARD — VUE_CONSO
|
||||
# ============================================================
|
||||
|
||||
@app.get("/dashboard", tags=["Dashboard"])
|
||||
@router.get("/dashboard", tags=["Dashboard"])
|
||||
def get_dashboard(
|
||||
service : Optional[str] = Query(None, description="Filtrer par service"),
|
||||
categorie : Optional[str] = Query(None, description="Filtrer par catégorie"),
|
||||
@@ -289,7 +331,7 @@ def get_dashboard(
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.get("/dashboard/summary", tags=["Dashboard"])
|
||||
@router.get("/dashboard/summary", tags=["Dashboard"])
|
||||
def get_dashboard_summary():
|
||||
"""KPI globaux pour les 4 cartes du dashboard."""
|
||||
with get_cursor() as cursor:
|
||||
@@ -318,7 +360,7 @@ def get_dashboard_summary():
|
||||
# HISTORIQUE — TABLE_FINAL
|
||||
# ============================================================
|
||||
|
||||
@app.get("/historique", tags=["Historique"])
|
||||
@router.get("/historique", tags=["Historique"])
|
||||
def get_historique(
|
||||
id_monito : Optional[int] = Query(None),
|
||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
@@ -353,7 +395,7 @@ def get_historique(
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.get("/historique/{id_monito}/evolution", tags=["Historique"])
|
||||
@router.get("/historique/{id_monito}/evolution", tags=["Historique"])
|
||||
def get_evolution(
|
||||
id_monito : int,
|
||||
date_debut : Optional[date] = Query(None),
|
||||
@@ -384,7 +426,7 @@ def get_evolution(
|
||||
return {"id_monito": id_monito, "points": len(data), "evolution": data}
|
||||
|
||||
|
||||
@app.get("/historique/comparaison", tags=["Historique"])
|
||||
@router.get("/historique/comparaison", tags=["Historique"])
|
||||
def get_comparaison(
|
||||
date_debut : Optional[date] = Query(None),
|
||||
date_fin : Optional[date] = Query(None),
|
||||
@@ -422,7 +464,7 @@ def get_comparaison(
|
||||
# SERVICE → regroupement par service (Contrat / Fournisseur)
|
||||
# ============================================================
|
||||
|
||||
@app.get("/evolution/global", tags=["Évolution globale"])
|
||||
@router.get("/evolution/global", tags=["Évolution globale"])
|
||||
def get_evolution_global(
|
||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
@@ -459,7 +501,7 @@ def get_evolution_global(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/evolution/par-monitoring", tags=["Évolution globale"])
|
||||
@router.get("/evolution/par-monitoring", tags=["Évolution globale"])
|
||||
def get_evolution_par_monitoring(
|
||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
@@ -513,7 +555,7 @@ def get_evolution_par_monitoring(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/evolution/par-service", tags=["Évolution globale"])
|
||||
@router.get("/evolution/par-service", tags=["Évolution globale"])
|
||||
def get_evolution_par_service(
|
||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||
@@ -583,3 +625,178 @@ def health_check():
|
||||
"version" : Config.API_VERSION,
|
||||
"nb_monitorings" : len(MONITO_TABLES),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AUTHENTIFICATION
|
||||
# ============================================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
role: str
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
actif: Optional[bool] = None
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/auth/login", tags=["Auth"])
|
||||
@limiter.limit("5/minute")
|
||||
def login(request: Request, form: OAuth2PasswordRequestForm = Depends()):
|
||||
"""Authentifie un utilisateur et retourne un JWT."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id_user, username, password_hash, role, actif "
|
||||
"FROM [USER] WHERE username = ?", form.username
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or not row[4] or not verify_password(form.password, row[2]):
|
||||
raise HTTPException(status_code=401, detail="Identifiants invalides")
|
||||
ip = request.client.host if request.client else None
|
||||
cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0])
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, 'LOGIN', ?)",
|
||||
row[0], row[1], ip
|
||||
)
|
||||
token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]})
|
||||
return {"access_token": token, "token_type": "bearer",
|
||||
"user": {"username": row[1], "role": row[3]}}
|
||||
|
||||
|
||||
@app.get("/auth/me", tags=["Auth"])
|
||||
def me(user: dict = Depends(get_current_user)):
|
||||
"""Profil de l'utilisateur connecté."""
|
||||
return user
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ADMINISTRATION (réservé Admin)
|
||||
# ============================================================
|
||||
|
||||
@app.get("/admin/users", tags=["Admin"])
|
||||
def list_users(admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id_user, username, email, role, actif, created_at, last_login "
|
||||
"FROM [USER] ORDER BY id_user"
|
||||
)
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
@app.post("/admin/users", tags=["Admin"], status_code=201)
|
||||
def create_user(body: UserCreate, admin: dict = Depends(require_admin)):
|
||||
if body.role not in ("Admin", "Superviseur", "Consultant"):
|
||||
raise HTTPException(status_code=400, detail="Rôle invalide")
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)",
|
||||
body.username, body.email, hash_password(body.password), body.role
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'CREATE_USER', ?)",
|
||||
admin["id_user"], admin["username"], body.username
|
||||
)
|
||||
return {"status": "created", "username": body.username}
|
||||
|
||||
|
||||
@app.put("/admin/users/{id_user}", tags=["Admin"])
|
||||
def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)):
|
||||
sets, params = [], []
|
||||
if body.email is not None:
|
||||
sets.append("email = ?"); params.append(body.email)
|
||||
if body.role is not None:
|
||||
if body.role not in ("Admin", "Superviseur", "Consultant"):
|
||||
raise HTTPException(status_code=400, detail="Rôle invalide")
|
||||
sets.append("role = ?"); params.append(body.role)
|
||||
if body.actif is not None:
|
||||
sets.append("actif = ?"); params.append(1 if body.actif else 0)
|
||||
if not sets:
|
||||
raise HTTPException(status_code=400, detail="Aucun champ à modifier")
|
||||
params.append(id_user)
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params)
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'UPDATE_USER', ?)",
|
||||
admin["id_user"], admin["username"], str(id_user)
|
||||
)
|
||||
return {"status": "updated", "id_user": id_user}
|
||||
|
||||
|
||||
@app.delete("/admin/users/{id_user}", tags=["Admin"])
|
||||
def delete_user(id_user: int, admin: dict = Depends(require_admin)):
|
||||
"""Suppression douce (actif = 0)."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user)
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'DELETE_USER', ?)",
|
||||
admin["id_user"], admin["username"], str(id_user)
|
||||
)
|
||||
return {"status": "deactivated", "id_user": id_user}
|
||||
|
||||
|
||||
@app.post("/admin/users/{id_user}/reset-password", tags=["Admin"])
|
||||
def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?",
|
||||
hash_password(body.password), id_user)
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'RESET_PASSWORD', ?)",
|
||||
admin["id_user"], admin["username"], str(id_user)
|
||||
)
|
||||
return {"status": "password_reset", "id_user": id_user}
|
||||
|
||||
|
||||
@app.get("/admin/journal", tags=["Admin"])
|
||||
def get_journal(limit: int = 200, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT TOP (?) date_action, username, action, detail, ip "
|
||||
"FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit
|
||||
)
|
||||
return rows_to_list(cursor, cursor.fetchall())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RGPD — droits de la personne
|
||||
# ============================================================
|
||||
|
||||
@app.get("/me/data-export", tags=["RGPD"])
|
||||
def export_my_data(user: dict = Depends(get_current_user)):
|
||||
"""Droit à la portabilité (art. 20) : export des données de l'utilisateur."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id_user, username, email, role, actif, created_at, last_login "
|
||||
"FROM [USER] WHERE username = ?", user["username"]
|
||||
)
|
||||
u = cursor.fetchone()
|
||||
profile = row_to_dict(cursor, u) if u else {}
|
||||
cursor.execute(
|
||||
"SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT "
|
||||
"WHERE username = ? ORDER BY date_action DESC", user["username"]
|
||||
)
|
||||
journal = rows_to_list(cursor, cursor.fetchall())
|
||||
return {"profile": profile, "journal": journal}
|
||||
|
||||
|
||||
@app.delete("/me", tags=["RGPD"])
|
||||
def delete_my_account(user: dict = Depends(get_current_user)):
|
||||
"""Droit à l'oubli (art. 17) : désactivation + anonymisation."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE [USER] SET actif = 0, "
|
||||
"username = CONCAT('deleted_', id_user), "
|
||||
"email = CONCAT('deleted_', id_user, '@deleted.local') "
|
||||
"WHERE username = ?", user["username"]
|
||||
)
|
||||
cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"])
|
||||
return {"status": "account_deleted"}
|
||||
|
||||
|
||||
# Enregistre les routes "données" protégées par JWT.
|
||||
app.include_router(router)
|
||||
|
||||
@@ -2,3 +2,8 @@ fastapi==0.136.0
|
||||
uvicorn==0.46.0
|
||||
pyodbc==5.3.0
|
||||
requests==2.33.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
slowapi==0.1.9
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Fixtures de test : client FastAPI + curseur SQL simulé (aucune vraie BDD)."""
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from auth import create_access_token
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""Curseur pyodbc simulé : on règle description / rows / one par test."""
|
||||
def __init__(self):
|
||||
self.description = []
|
||||
self._rows = []
|
||||
self._one = None
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, *args):
|
||||
self.executed.append((query, args))
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._one
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cur(monkeypatch):
|
||||
c = FakeCursor()
|
||||
|
||||
@contextmanager
|
||||
def fake_get_cursor():
|
||||
yield c
|
||||
|
||||
monkeypatch.setattr(main, "get_cursor", fake_get_cursor)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(main.app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
token = create_access_token({"sub": "admin", "role": "Admin", "uid": 1})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests d'endpoints (curseur SQL simulé via la fixture `cur`)."""
|
||||
from auth import hash_password, create_access_token
|
||||
|
||||
|
||||
def test_health_ok(client, cur):
|
||||
cur._one = (1,)
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["api"] == "ok"
|
||||
|
||||
|
||||
def test_categories_requires_auth(client):
|
||||
# sans token -> 401
|
||||
assert client.get("/categories").status_code == 401
|
||||
|
||||
|
||||
def test_services_returns_list(client, cur, auth_headers):
|
||||
cur.description = [("id_service",), ("nom_service",)]
|
||||
cur._rows = [(1, "Contrat"), (2, "Fournisseur")]
|
||||
r = client.get("/services", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert {"id_service": 1, "nom_service": "Contrat"} in r.json()
|
||||
|
||||
|
||||
def test_auth_login_success(client, cur):
|
||||
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
|
||||
r = client.post("/auth/login", data={"username": "admin", "password": "Admin2026!"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["user"]["role"] == "Admin"
|
||||
|
||||
|
||||
def test_auth_login_wrong_password(client, cur):
|
||||
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
|
||||
r = client.post("/auth/login", data={"username": "admin", "password": "WRONG"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_auth_me(client, auth_headers):
|
||||
r = client.get("/auth/me", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_monitoring_unknown_returns_404(client, cur, auth_headers):
|
||||
r = client.get("/monitorings/999/details", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_admin_forbidden_for_consultant(client):
|
||||
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
|
||||
r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 403
|
||||
Reference in New Issue
Block a user