feat(backend): ajoute les comptes applicatifs et l'amorçage du premier admin

Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite
`user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte
applicatif, par opposition au rôle PostgreSQL qui portera le
cantonnement des accès ETL et ML.

`credentials_changed_at` couvre à elle seule le changement de mot de
passe, le changement de rôle et la désactivation : tout jeton émis avant
cet instant sera refusé, sans attendre son expiration.

La configuration refuse désormais de démarrer sur cinq erreurs
silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug`
en production, joker CORS, origines vides hors local, et cookie
`SameSite=None` sans `Secure`. Les fixtures de test et les deux
`.env.example` suivent, sans quoi rien ne démarrerait.

Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout
`ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic
qui insérerait ce compte graverait son empreinte dans Git pour toujours.
This commit is contained in:
Johan LEROY
2026-09-15 14:30:05 +02:00
parent 53af7a76d8
commit a8f59e6e76
14 changed files with 615 additions and 21 deletions
+104
View File
@@ -0,0 +1,104 @@
# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui
# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe
# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le
# schéma, pas les données.
# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par
# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande.
import argparse
import asyncio
import secrets
import sys
from getpass import getpass
from app.core.config import Settings, get_settings
from app.core.hashing import build_hasher
from app.core.roles import Role
from app.db.session import get_session_factory
from app.repositories.user import UserRepository
LONGUEUR_MOT_DE_PASSE_GENERE = 24
LONGUEUR_MINIMALE = 12
async def create_admin(
settings: Settings, *, email: str, password: str, force: bool
) -> tuple[bool, str]:
hacheur = build_hasher(
time_cost=settings.argon2_time_cost,
memory_cost_kib=settings.argon2_memory_cost_kib,
parallelism=settings.argon2_parallelism,
max_concurrency=settings.argon2_max_concurrency,
)
empreinte = await hacheur.hash(password)
async with get_session_factory()() as session:
depot = UserRepository(session)
if not force and await depot.count_active_admins() > 0:
return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer"
if await depot.get_by_email(email) is not None:
return False, f"Le compte {email} existe déjà"
await depot.create(
email=email,
password_hash=empreinte,
role=Role.ADMIN,
must_change_password=True,
)
await session.commit()
return (
True,
f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion",
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
sous_commandes = parser.add_subparsers(dest="commande", required=True)
admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur")
admin.add_argument("--email", required=True)
admin.add_argument(
"--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois"
)
admin.add_argument(
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
)
return parser
def read_password(*, generate: bool) -> str:
if generate:
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe
mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < LONGUEUR_MINIMALE:
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe
def main(argv: list[str] | None = None) -> int:
arguments = build_parser().parse_args(argv)
mot_de_passe = read_password(generate=arguments.generate)
succes, message = asyncio.run(
create_admin(
get_settings(),
email=arguments.email,
password=mot_de_passe,
force=arguments.force,
)
)
print(message)
return 0 if succes else 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+70 -2
View File
@@ -1,10 +1,16 @@
from functools import lru_cache
from typing import Literal
from typing import Literal, Self
from pydantic import Field, SecretStr
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "dev", "staging", "prod"]
SameSite = Literal["lax", "strict", "none"]
SECRET_KEY_MIN_LENGTH = 32
SENTINELLES_INTERDITES = frozenset(
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
)
class Settings(BaseSettings):
@@ -27,6 +33,30 @@ class Settings(BaseSettings):
database_pool_size: int = 5
database_max_overflow: int = 10
jwt_issuer: str = "enervision-api"
jwt_audience: str = "enervision-web"
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
refresh_cookie_name: str = "ev_refresh"
cookie_path: str = "/api/v1/auth"
cookie_samesite: SameSite = "strict"
cookie_secure: bool | None = None
argon2_time_cost: int = Field(default=2, ge=1, le=10)
argon2_memory_cost_kib: int = Field(default=19456, ge=8192)
argon2_parallelism: int = Field(default=1, ge=1, le=4)
argon2_max_concurrency: int = Field(default=4, ge=1, le=32)
login_window_seconds: int = Field(default=900, ge=60)
login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1)
login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, ge=1)
trust_proxy_headers: bool = False
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
@property
def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@@ -35,6 +65,44 @@ class Settings(BaseSettings):
def is_production(self) -> bool:
return self.env == "prod"
@property
def cookies_are_secure(self) -> bool:
return self.env != "local" if self.cookie_secure is None else self.cookie_secure
@property
def api_docs_are_exposed(self) -> bool:
if self.expose_api_docs is not None:
return self.expose_api_docs
return self.env not in ("staging", "prod")
@model_validator(mode="after")
def _refuse_les_configurations_dangereuses(self) -> Self:
secret = self.secret_key.get_secret_value()
if len(secret) < SECRET_KEY_MIN_LENGTH:
raise ValueError(
f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères"
)
if secret.strip().lower() in SENTINELLES_INTERDITES:
raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une")
# Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète
# au client, et à l'engine, qui journalise le SQL et ses paramètres.
if self.debug and self.env in ("staging", "prod"):
raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux")
if "*" in self.cors_origins:
raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées")
# Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des
# routes d'authentification n'a plus de référentiel auquel comparer.
if self.env != "local" and not self.allowed_origins:
raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local")
if self.cookie_samesite == "none" and not self.cookies_are_secure:
raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure")
return self
@lru_cache
def get_settings() -> Settings:
+54
View File
@@ -0,0 +1,54 @@
# Piège : le cookie de suppression doit reprendre exactement le nom et le `Path` du cookie
# posé, sinon le navigateur en garde une copie et la déconnexion n'est que cosmétique.
# `RefreshCookie.expired()` existe pour que les deux ne puissent pas diverger.
from dataclasses import asdict, dataclass
from typing import Any, Self
from app.core.config import SameSite, Settings
SECURE_PREFIX = "__Secure-"
@dataclass(frozen=True, slots=True)
class RefreshCookie:
key: str
value: str
max_age: int
path: str
secure: bool
httponly: bool
samesite: SameSite
@classmethod
def build(cls, settings: Settings, value: str) -> Self:
return cls(
key=cookie_name(settings),
value=value,
max_age=settings.refresh_token_ttl_seconds,
path=settings.cookie_path,
secure=settings.cookies_are_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
@classmethod
def expired(cls, settings: Settings) -> Self:
return cls(
key=cookie_name(settings),
value="",
max_age=0,
path=settings.cookie_path,
secure=settings.cookies_are_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
def as_kwargs(self) -> dict[str, Any]:
return asdict(self)
def cookie_name(settings: Settings) -> str:
if settings.cookies_are_secure:
return f"{SECURE_PREFIX}{settings.refresh_cookie_name}"
return settings.refresh_cookie_name
+4
View File
@@ -1,2 +1,6 @@
# Piège : tout modèle absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui générerait alors un drop de sa table.
from app.models.user import AppUser
__all__ = ["AppUser"]
+50
View File
@@ -0,0 +1,50 @@
# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL,
# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par
# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès.
import uuid
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.roles import AccountKind, Role
from app.db.base import Base
ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role)
NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind)
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"),
CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"),
CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
must_change_password: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
# Une seule colonne couvre le changement de mot de passe, le changement de rôle et la
# désactivation : tout jeton émis avant cet instant est périmé.
credentials_changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
full_name: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+97
View File
@@ -0,0 +1,97 @@
# Piège : `set_role()` et `set_active()` avancent `credentials_changed_at`. C'est ce qui rend
# un changement de rôle ou une désactivation effectifs à la requête suivante au lieu d'attendre
# l'expiration du jeton d'accès. Une mise à jour qui l'oublierait laisserait 15 minutes de
# privilèges périmés.
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import AccountKind, Role
from app.models.user import AppUser
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_email(self, email: str) -> AppUser | None:
requete = select(AppUser).where(AppUser.email == email.strip().lower())
return (await self._session.execute(requete)).scalar_one_or_none()
async def get_by_id(self, user_id: UUID) -> AppUser | None:
return await self._session.get(AppUser, user_id)
async def list_all(self) -> Sequence[AppUser]:
requete = select(AppUser).order_by(AppUser.email)
return (await self._session.execute(requete)).scalars().all()
async def count_active_admins(self) -> int:
requete = (
select(func.count())
.select_from(AppUser)
.where(AppUser.role == Role.ADMIN.value, AppUser.is_active.is_(True))
)
return (await self._session.execute(requete)).scalar_one()
async def create(
self,
*,
email: str,
password_hash: str,
role: Role,
kind: AccountKind = AccountKind.HUMAIN,
full_name: str | None = None,
must_change_password: bool = False,
) -> AppUser:
compte = AppUser(
email=email.strip().lower(),
password_hash=password_hash,
role=role.value,
kind=kind.value,
full_name=full_name,
must_change_password=must_change_password,
)
self._session.add(compte)
await self._session.flush()
return compte
async def update_password(
self, user_id: UUID, password_hash: str, *, must_change_password: bool
) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(
password_hash=password_hash,
must_change_password=must_change_password,
credentials_changed_at=func.now(),
)
)
async def rehash_password(self, user_id: UUID, password_hash: str) -> None:
# Un simple recalcul avec des paramètres Argon2 plus récents ne périme aucun jeton.
await self._session.execute(
update(AppUser).where(AppUser.id == user_id).values(password_hash=password_hash)
)
async def touch_last_login(self, user_id: UUID) -> None:
await self._session.execute(
update(AppUser).where(AppUser.id == user_id).values(last_login_at=func.now())
)
async def set_role(self, user_id: UUID, role: Role) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(role=role.value, credentials_changed_at=func.now())
)
async def set_active(self, user_id: UUID, *, is_active: bool) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(is_active=is_active, credentials_changed_at=func.now())
)