`ROUTES_A_ROLE` était recopiée dans `test_openapi.py`, et deux de ses entrées portaient
`{id}` là où le contrat expose `{user_id}`. Elles ne correspondaient donc à aucune
opération, et `test_every_role_guarded_route_documents_the_role_refusal` passait au vert
sans rien vérifier sur `PATCH /users/{user_id}` ni sur sa réinitialisation de mot de passe :
11 des 13 routes gardées étaient réellement couvertes.
`tests/api/acces.py` porte désormais la classification des 24 routes du contrat en quatre
ensembles, dont la table `ROLE_MINIMUM`, et `test_every_declared_route_is_classified` refuse
aussi bien une route non classée qu'une entrée qui ne correspond plus à rien. C'est ce que
`docs/architecture/20-backend.md` annonçait comme impossible : « ces deux listes sont
maintenues à la main, pas dérivées ».
Au passage, `chemin_concret()` substitue les trois gabarits du contrat et non plus le seul
`{user_id}`, ce qui est sans effet sur le refus anonyme mais nécessaire à un appel qui doit
aboutir.
87 lines
3.8 KiB
Python
87 lines
3.8 KiB
Python
# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py,
|
|
# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune
|
|
# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que
|
|
# rien ne le signale.
|
|
# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est
|
|
# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture.
|
|
|
|
from typing import Final
|
|
|
|
from app.core.roles import Role
|
|
|
|
Route = tuple[str, str]
|
|
|
|
ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset(
|
|
{
|
|
("GET", "/api/v1/health/live"),
|
|
("GET", "/api/v1/health/ready"),
|
|
("POST", "/api/v1/auth/login"),
|
|
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
|
("POST", "/api/v1/auth/logout"),
|
|
("POST", "/api/v1/auth/forgot-password"),
|
|
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
|
|
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
|
|
("POST", "/api/v1/auth/reset-password"),
|
|
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
|
|
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
|
|
("GET", "/api/v1/auth/reset-password/validate"),
|
|
("GET", "/metrics"),
|
|
}
|
|
)
|
|
|
|
# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal`
|
|
# n'est construit et `require_role` n'entre jamais en jeu.
|
|
ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")})
|
|
|
|
# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du
|
|
# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état
|
|
# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py.
|
|
ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset(
|
|
{
|
|
("GET", "/api/v1/auth/me"),
|
|
("POST", "/api/v1/auth/password"),
|
|
("POST", "/api/v1/auth/logout-all"),
|
|
}
|
|
)
|
|
|
|
ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
|
("GET", "/api/v1/sites"): Role.LECTEUR,
|
|
("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR,
|
|
("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR,
|
|
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
|
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
|
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
|
|
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
|
|
("GET", "/api/v1/readings"): Role.LECTEUR,
|
|
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
|
("GET", "/api/v1/users"): Role.ADMIN,
|
|
("POST", "/api/v1/users"): Role.ADMIN,
|
|
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
|
|
("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN,
|
|
}
|
|
|
|
# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution
|
|
# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test
|
|
# passerait en prouvant autre chose que ce qu'il annonce.
|
|
SUBSTITUTIONS: Final[dict[str, str]] = {
|
|
"{user_id}": "00000000-0000-0000-0000-000000000000",
|
|
"{site_id}": "site-absent-du-jeu-de-donnees",
|
|
"{recommendation_id}": "999999999",
|
|
}
|
|
|
|
|
|
def chemin_concret(chemin: str) -> str:
|
|
for gabarit, valeur in SUBSTITUTIONS.items():
|
|
chemin = chemin.replace(gabarit, valeur)
|
|
return chemin
|
|
|
|
|
|
def routes_du_schema(schema: dict[str, object]) -> list[Route]:
|
|
chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment]
|
|
return [
|
|
(methode.upper(), chemin)
|
|
for chemin, operations in chemins.items()
|
|
for methode in operations
|
|
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
|
|
]
|