Aller au contenu
English

Ajouter la connexion et les rôles à une API existante

  • Étude de cas 2
  • 240 min
  • Facultative · 1 étude de cas requise pour le certificat

Ce que vous saurez faire

Prenez une API qui fonctionne pour une équipe et rendez-la sûre pour deux. L’API de données municipales du cours sur les API répond à quiconque possède son unique clé partagée. Dans cette étude de cas, un deuxième service municipal s’ajoute. L’API doit donc savoir qui l’appelle, pour quel service cette personne travaille et ce que son rôle lui permet de faire. Vous ajoutez la connexion, les rôles et les requêtes limitées au service, puis prouvez les règles avec une matrice de tests. Ensuite, vous exécutez la même API avec un vrai fournisseur d’identité.

Étude de casPièce de portfolio

Durée
Environ 4 h
S’exécute dans
VS Code, Python 3.12+
Compétences utilisées
  • Connexion OIDC avec PKCE
  • Validation des jetons d'accès
  • Adhésions et rôles
  • Requêtes limitées au service
  • Règles des codes 404 et 403
  • Événements d'audit
  • Matrice de tests du contrôle d'accès
  • Keycloak

Décompressez le projet de départ, ouvrez le dossier dans VS Code et suivez les étapes ci-dessous. Elles commencent par un nouvel environnement virtuel.

Le projet de départ poursuit l’étude de cas du cours sur les API, Créer une API de données sur les demandes de service municipal. Il reprend ce projet terminé et contient les données d’un deuxième service. Il contient aussi les éléments de ce cours que vous réutilisez tels quels. Ce sont le fournisseur d’exercice, le validateur de jetons, un script de connexion et les personnes fictives. Vous ajoutez la couche d’identité. Le projet terminé est le résultat. Le code est sous licence MIT, donc vous pouvez publier votre version.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 240 minutes

Ce que vous devez savoir

Cette étude de cas utilise tout le cours. Voici les leçons sur lesquelles elle s’appuie le plus :

Ce qu’il vous faut sur votre ordinateur

  • Python 3.12 ou une version plus récente, VS Code et curl, comme dans le cours.
  • Environ 150 Mo d’espace disque libre : les paquets et la base de données (13 Mo à télécharger, 65 Mo décompressés).
  • Pour la partie 8 seulement : Docker Desktop, environ 800 Mo pour l’image Keycloak et 1 Go de mémoire libre.

Testé sur macOS (Apple silicon). Versions : Python 3.12, FastAPI 0.142.2, PyJWT 2.15.1, pytest 9.1.1, Keycloak 26.8.0. Les commandes Windows suivent celles du cours, mais personne ne les a exécutées. La partie sur Microsoft Entra ID provient de sa documentation; personne ne l’a exécutée non plus.

L’équipe d’analyse de Teresa Okafor exploite l’API de données municipales du cours sur les API. Les tableaux de bord y lisent les demandes 311 du service des transports avec une seule clé partagée. Tout fonctionne bien, alors le service des parcs demande la même chose. Il veut ses demandes concernant les arbres et les parcs, ses propres tableaux de bord et une nouvelle fonction commune aux deux services. Leur personnel veut ajouter de courtes notes aux demandes, comme « Équipe envoyée le 4 mars. »

Le plan rapide consiste à charger les données des parcs dans la même base et à donner la clé aux parcs. Lena Fischer, qui dirige l’équipe des parcs, pose trois questions avant d’accepter. Mon équipe peut-elle voir les demandes des transports? Un contractuel avec un accès en lecture seule peut-il aussi écrire des notes? Quand quelqu’un part, pouvons-nous lui retirer l’accès sans changer la clé pour tout le monde?

Avec une seule clé partagée, les réponses sont oui, oui et non. Ce sont les trois questions du cours dans un bureau municipal : qui appelle, dans quel service, et que peut faire cette personne? Vous devez développer la solution qui donnera les bonnes réponses.

Nous avons inventé les personnes et les plans des services. Les demandes sont réelles : elles proviennent des données ouvertes de la ville de New York.

La version 2 de city-requests-api. Les données restent en lecture seule. Les routes passent sous le service, et chaque route exige un jeton d’accès :

Point de terminaison Qui peut l’utiliser
GET /v1/me Quiconque possède un jeton valide : votre identité et vos adhésions
GET /v1/departments/{department}/service-requests, .../{id} Membres du service
GET .../service-requests/{id}/notes Membres
POST .../service-requests/{id}/notes Propriétaires et personnel
GET .../complaint-types, .../stats/daily Membres
GET .../members Propriétaires et personnel
PUT .../members/{user_id}, DELETE .../members/{user_id} Propriétaires
GET .../audit Propriétaires

Les personnes restent les mêmes dans chaque partie. Teresa (propriétaire), Priya Nair (personnel) et le serveur de tableaux de bord (transport-dashboards, lecture seule) travaillent aux transports. Lena (propriétaire), Jamal Carter (personnel) et Marcus Bell (lecture seule) travaillent aux parcs. Hana Sato a un accès en lecture seule aux transports et un rôle de personnel aux parcs : une personne, deux rôles. Oliver Grant peut se connecter, mais n’a aucune adhésion.

Les données des transports sont celles du cours sur les API : 109 915 demandes du Department of Transportation de la ville (organisme DOT), de janvier à juin 2025. Pour cette étude de cas, Nextia Learning a ajouté le Department of Parks and Recreation (organisme DPR). Le 10 octobre 2026, Nextia Learning a récupéré les demandes de ce service pour les mêmes mois dans NYC Open Data : 48 748 demandes. NYC Open Data indique : « Il n’y a aucune restriction sur l’utilisation des données ouvertes. » La ville demande aux utilisateurs de nommer la source et la version, et de décrire les changements. Le fichier DATASET.md du projet le fait.

Les neuf mêmes règles de nettoyage du cours sur les API s’appliquent aux deux services. Une dixième règle ajoute la colonne department : transport pour DOT et parks pour DPR. Les règles ont retiré 5 demandes en double des parcs; il reste donc 48 743 demandes des parcs. Les lignes des transports sont exactement celles du cours sur les API. Le script vérifie qu’aucun identifiant de demande ne figure dans les deux services.

Colonne Signification
id, created_at, closed_at, status, complaint_type, … Comme dans le cours sur les API
department transport ou parks : l’organisation dont les membres peuvent voir la demande

Examinez les données avec Python, dans le dossier du projet de départ après la configuration ci-dessous (tapez python, puis les lignes après >>>) :

>>> import sqlite3
>>> con = sqlite3.connect("data/service_requests_2025h1_v2.db")
>>> con.execute("SELECT department, COUNT(*) FROM service_requests GROUP BY department ORDER BY 2 DESC").fetchall()
[('transport', 109915), ('parks', 48743)]
>>> con.execute("SELECT complaint_type, COUNT(*) FROM service_requests WHERE department = 'parks' GROUP BY 1 ORDER BY 2 DESC LIMIT 3").fetchall()
[('Damaged Tree', 12198), ('Maintenance or Facility', 10482), ('Overgrown Tree/Branches', 8171)]
>>> con.execute("SELECT department, COUNT(*) FROM service_requests WHERE created_at >= '2025-03-03' AND created_at < '2025-03-04' GROUP BY 1 ORDER BY 2 DESC").fetchall()
[('transport', 752), ('parks', 163)]

Ce que cela nous apprend. Les deux services partagent une table et les mêmes jours. Le 3 mars 2025, 752 demandes concernaient les transports et 163, les parcs. Une requête qui oublie le service les mélange, sans rien qui le signale dans le résultat. Une ligne des parcs ressemble exactement à une ligne des transports. C’est la fuite à surveiller dans chaque partie ci-dessous.

Les données ne contiennent ni les noms ni les adresses des personnes qui ont appelé le 311.

  1. Téléchargez le projet de départ depuis l’encadré en haut de cette page. Décompressez-le dans votre dossier de projets. Vous obtenez le dossier city-requests-api. Si vous avez encore le dossier du même nom du cours sur les API, décompressez celui-ci ailleurs.

  2. Dans VS Code, choisissez File > Open Folder (Fichier, ouvrir un dossier), puis ouvrez city-requests-api. Ouvrez ensuite un terminal : Terminal > New Terminal (Terminal, nouveau terminal).

  3. Créez et activez un environnement virtuel :

    Fenêtre de terminal
    python3 -m venv .venv
    source .venv/bin/activate

    L’invite commence par (.venv). Si VS Code propose d’utiliser le nouvel environnement pour ce dossier, choisissez Yes (oui).

  4. Installez les versions exactes. La liste est celle du cours sur les API, avec PyJWT et cryptography pour les jetons :

    Fenêtre de terminal
    python -m pip install -r requirements-lock.txt
  5. Téléchargez la base de données, vérifiez-la et décompressez-la :

    Fenêtre de terminal
    python scripts/get_db.py
    Sortie
    Downloading https://github.com/Nextia-Intelligence/nextia-learning-labs/raw/main/C23/M07-L02-add-sign-in-and-roles-to-an-existing-api/data/service_requests_2025h1_v2.db.gz
    Checked service_requests_2025h1_v2.db.gz: SHA-256 f6d4201fd849... is correct
    Wrote data/service_requests_2025h1_v2.db: transport 109,915, parks 48,743 service requests
  6. Exécutez les tests du cours sur les API :

    Fenêtre de terminal
    python -m pytest
    Sortie (fin)
    ============================== 67 passed in 0.59s ==============================

Ce que cela nous apprend. L’API du cours sur les API fonctionne avec la version 2 des données, et ses 67 tests réussissent. Ils réussissent parce que leur petite base de test contient seulement des lignes des transports. Les tests ne peuvent pas encore détecter le problème.

Partie 1 : la référence de base, une seule clé partagée

Section intitulée « Partie 1 : la référence de base, une seule clé partagée »

Une étude de cas a besoin d’une référence de base : la solution la plus simple que l’équipe adopterait. Ici, c’est le plan rapide : les parcs reçoivent la clé. Démarrez l’API du projet de départ avec la clé, comme dans le cours sur les API :

Fenêtre de terminal
API_KEY=local-dev-key fastapi dev

Dans un deuxième terminal (le + du panneau du terminal; activez aussi l’environnement), exécutez les cinq tentatives du projet de départ. Chacune correspond à une question de Lena. Chaque tentative envoie la même clé, car c’est tout ce que possède la personne qui appelle :

Second terminal
python -m scripts.attempts_shared_key
Sortie
GOT IT Jamal (parks) reads transport request 64246800: 200 Street Sign - Missing
GOT IT Jamal lists requests: 94 of the first 100 are transport's
GOT IT Lena (parks) opens the complaint-type menu: 31 of 42 types are transport's, first 'Street Condition'
GOT IT Transport's dashboard, 3 March 2025: opened 915, of which 163 are parks requests
GOT IT Oliver left the team last month; his copy of the key still works: 200
5 of 5 attempts got what they should not get. The API saw the same caller every time: the key.

Ce que cela nous apprend. Chaque tentative a fonctionné, et l’API ne peut même pas distinguer les personnes qui l’appellent. Deux échecs sont des fuites : le personnel des parcs lit les demandes des transports. Deux autres donnent des nombres erronés, tout aussi nuisibles : le tableau de bord des transports indique maintenant 915 demandes ouvertes le 3 mars, et non 752. Personne ne reçoit d’erreur, alors personne ne le remarque. Le dernier concerne le temps : personne ne peut retirer la clé à une seule personne. C’est le résultat à améliorer, 5 sur 5. Arrêtez l’API avec Ctrl+C.

Les parties 4 à 6 modifient des fichiers qui dépendent les uns des autres. L’API fonctionne donc à nouveau seulement à la fin de la partie 6. Une erreur d’importation dans fastapi dev entre ces parties est normale.

Partie 2 : se connecter avec le fournisseur d’exercice

Section intitulée « Partie 2 : se connecter avec le fournisseur d’exercice »

Le projet de départ contient le fournisseur d’identité d’exercice du cours dans idp/. C’est une simulation : il signe de vrais jetons RS256, mais affiche des personnes fictives au lieu de demander un mot de passe. C’est une copie du fournisseur du projet du cours, avec sa licence MIT. Quatre éléments ont changé :

  • les noms des applications;
  • les portées : requests:read, requests:write et members:manage;
  • l’audience : city-requests-api;
  • le port : 8410.

Le fournisseur du projet du cours utilise le port 8400, donc les deux peuvent fonctionner en même temps.

Créez la clé de signature du fournisseur, puis démarrez-le dans le deuxième terminal :

Second terminal
python -m idp init
python -m idp
Sortie
Made a signing key (N8WXOiTO1y0iX_Ni) and the clients in /.../city-requests-api/.idp.
Wrote the client secret of transport-dashboards to /.../city-requests-api/.env.
The provider keeps only its SHA-256. Never commit .env or .idp/.
Practice identity provider: http://localhost:8410 (made-up people, no passwords)

Votre identifiant de clé est différent : votre ordinateur l’a créé. init a aussi créé .env à partir de .env.example, avec OIDC_ISSUER=http://localhost:8410 à l’intérieur. Dans un troisième terminal, connectez-vous comme Hana avec le script du projet de départ. C’est l’application city-cli : un client public. Elle utilise donc le flux de code d’autorisation avec PKCE, comme dans Flux d’application :

Third terminal
python -m scripts.login --user usr-hana
Sortie
1. The sign-in page of the practice provider, with a PKCE challenge: http://localhost:8410/authorize?response_type=code&client_id...
You picked usr-hana.
2. The provider sent the browser back with a one-time code; the state matches.
3. The code and the PKCE verifier were swapped for tokens (POST /token):
200: token_type Bearer, expires_in 600 s
access_token eyJhbGciOiJSUzI1NiIsImtp...(742 characters)
id_token eyJhbGciOiJSUzI1NiIsImtp...(713 characters)
access_token (decoded, not checked):
header {"alg": "RS256", "kid": "N8WXOiTO1y0iX_Ni", "typ": "at+jwt"}
claims {
"iss": "http://localhost:8410",
"sub": "usr-hana",
"aud": "city-requests-api",
"exp": 1791618331,
"nbf": 1791617731,
"iat": 1791617731,
"jti": "ad913838abef41deb96972aa526c36fc",
"client_id": "city-cli",
"scope": "requests:read requests:write members:manage"
}
Saved in .tokens/usr-hana.json.

Ce que cela nous apprend. Le jeton indique qui est Hana (sub), qui a créé le jeton (iss) et à quelle API il est destiné (aud). Il indique aussi jusqu’à quand il est valide (exp) et ce que l’application peut faire pour elle (scope). Il ne dit rien sur les services ou les rôles, volontairement. Hana a deux rôles, et l’API répond aux deux questions à partir de sa propre table.

Le fichier de données est en lecture seule, et il le reste. Les autorisations vont dans un deuxième petit fichier SQLite où l’API peut écrire : data/access.db. Le projet de départ contient son schéma, city_api/access_schema.sql, et le script qui le remplit à partir de data/identity/ :

Third terminal
python -m scripts.init_access
Sortie
Wrote data/access.db: 2 departments, 8 users, 8 memberships.

Les lignes importantes du schéma :

city_api/access_schema.sql (part)
-- The role belongs to the membership, not to the person: Hana is read_only in
-- transport and staff in parks.
CREATE TABLE memberships (
department_id TEXT NOT NULL REFERENCES departments (department_id),
user_id TEXT NOT NULL REFERENCES users (user_id),
role TEXT NOT NULL CHECK (role IN ('owner', 'staff', 'read_only')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY (department_id, user_id)
);
-- Append-only: nobody changes or deletes an event, not even the API.
CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END;
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END;

Les autres tables sont departments, users (selon le sub du fournisseur), request_notes et audit_events. Une note pointe vers une demande dans l’autre fichier, donc SQLite ne peut pas vérifier ce lien avec une clé étrangère. L’API le vérifie à sa place : elle écrit une note seulement après avoir trouvé la demande dans le service de la personne qui appelle.

L’API a besoin d’une connexion à ce fichier. Remplacez city_api/db.py par la version du projet terminé. Elle ajoute connect_access(), qui ouvre le fichier en écriture et vérifie la version de son schéma.

Le validateur est celui du cours, copié dans le projet de départ sous le nom city_api/auth.py. Il vérifie un jeton dans un ordre fixe et s’arrête au premier échec. Il vérifie que le jeton est un JWT, puis l’algorithme (RS256 seulement) et le type (at+jwt). Ensuite, il vérifie la clé, la signature, l’expiration, l’émetteur, l’audience et les revendications (claims) nécessaires. Il récupère les clés publiques du fournisseur une seule fois et les conserve. Vous le raccordez dans quatre fichiers. Chaque étape indique quel fichier remplacer par celui du projet terminé et montre la partie importante.

Paramètres. Remplacez city_api/config.py par la version du projet terminé. Elle retire API_KEY, lit .env et ajoute les paramètres d’identité. Chacun a une valeur par défaut sûre, donc le fichier .env de la partie 2 suffit :

city_api/config.py (the new settings)
access_db_path: Path = Path("data/access.db")
oidc_issuer: str | None = None # not set: sign-in is off, and /v1 answers 503
oidc_audience: str = "city-requests-api"
oidc_internal_url: str | None = None # where the API reaches the provider, if different
access_token_types: tuple[str, ...] = tuple(DEFAULT_TOKEN_TYPES.split(","))
jwks_cache_seconds: float = 600
jwks_min_refresh_seconds: float = 10
token_leeway_seconds: float = 30
user_id_claim: str = "sub" # Microsoft Entra ID: "oid" (see docs/entra-id.md)

Qui appelle. Remplacez city_api/security.py. La vérification de la clé d’API disparaît; cette dépendance prend sa place :

city_api/security.py (the dependency)
def current_identity(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
) -> Identity:
validator = request.app.state.validator
if validator is None:
raise SignInUnavailable("OIDC_ISSUER is not set")
if credentials is None:
raise NotSignedIn("Send an access token: Authorization: Bearer <token>.")
identity = validator.validate(credentials.credentials) # raises TokenRejected
request.state.identity = identity # for the access log
return identity
CurrentIdentity = Annotated[Identity, Depends(current_identity)]

Les réponses. Remplacez city_api/errors.py. Sa nouvelle section « connexion et rôles » renvoie pour un jeton refusé 401 invalid_token (jeton non valide), un en-tête WWW-Authenticate et un en-tête X-Token-Check qui nomme la vérification. Un jeton refusé produit aussi un événement d’audit, sans le jeton.

L’application. Remplacez city_api/main.py. Il crée le validateur une seule fois, au démarrage, et inclut les nouveaux routeurs des parties 5 et 6. Il consigne aussi l’identifiant de la personne qui appelle avec chaque requête :

city_api/main.py (in create_app)
app.state.validator = make_validator(settings)
city_api/main.py (the access log line)
identity = getattr(request.state, "identity", None)
logger.info(
"%s %s %d %.0fms id=%s user=%s",
request.method,
request.url.path,
response.status_code,
(time.perf_counter() - started) * 1000,
request.state.request_id,
identity.user_id if identity else "-",
)

Le journal répond maintenant à « qui a lu la demande 64246800? » par une personne, et non « une clé ».

Partie 5 : le service dans le chemin, le rôle dans la table

Section intitulée « Partie 5 : le service dans le chemin, le rôle dans la table »

Pensez à une carte d’accès à un immeuble de bureaux. L’agent de sécurité vérifie d’abord que la carte est authentique (qui). Ensuite, il vérifie que la carte donne accès à cet étage (où). Puis une affiche sur la porte indique ce que vous pouvez faire à l’intérieur (quoi). Une vraie carte pour l’étage 3 n’ouvre pas l’étage 5. L’agent ne vous dit pas non plus ce qui se trouve à l’étage 5.

L’API fait la même chose pour chaque route de service, dans cet ordre :

Une requête GET /v1/departments/transport/service-requests/64246800 avec un jeton de porteur passe par quatre vérifications successives. 1. Qui appelle? L'algorithme, le type, la clé, l'expiration, l'émetteur et l'audience du jeton; non : 401 invalid_token (jeton non valide), pour un jeton d'identité, un jeton expiré ou l'ancienne clé d'API. 2. Membre du service dans le chemin? Une ligne memberships pour transport et l'utilisateur; non : 404 Not found. (introuvable), pour Oliver, pour Jamal des parcs ou pour un service inventé. 3. Le rôle et la portée du jeton le permettent-ils? decide(role, action, scopes); non : 403 forbidden (interdit), quand Hana, en lecture seule, ajoute une note. 4. La demande est-elle dans ce service? WHERE department =? AND id =?; non : 404 No service request has the ID 64225360 (aucune demande de service ne porte l'identifiant 64225360), quand Priya demande un identifiant des parcs par les transports. Oui après les quatre : 200 OK, Priya lit 64246800.
Les quatre vérifications de chaque route sous /v1/departments/{department}/, dans l'ordre. Chaque « non » a sa propre réponse, et les vérifications s'arrêtent là.
Description textuelle du diagramme

Une requête nomme son service dans le chemin et contient un jeton. La vérification 1 demande qui appelle : sans jeton valide, la réponse est 401. La vérification 2 cherche une ligne d’adhésion de cette personne dans ce service. Sans adhésion, la réponse est 404 « Not found. » (introuvable), pour un service réel comme pour un service inventé. La vérification 3 demande à la matrice d’accès si le rôle et la portée du jeton permettent l’action. Sinon, la réponse est 403. La vérification 4 est la requête SQL elle-même, qui inclut le service. Une demande d’un autre service reste introuvable : 404. Seule une requête qui réussit les quatre vérifications reçoit 200.

Créez city_api/access.py. La table des rôles est la matrice d’accès de Utilisateurs et organisations, adaptée à cette API :

city_api/access.py (the rules)
ACTIONS = ("request.read", "note.write", "member.read", "member.manage", "audit.read")
# What each role may do in its department.
ROLE_ACTIONS: dict[str, frozenset[str]] = {
"owner": frozenset(ACTIONS),
"staff": frozenset({"request.read", "note.write", "member.read"}),
"read_only": frozenset({"request.read"}),
}
# The token scope that an action needs. A role says what the PERSON may do; a scope says
# what the APPLICATION may do for them (what they allowed at sign-in). Both must allow it.
ACTION_SCOPE = {
"request.read": "requests:read",
"member.read": "requests:read",
"note.write": "requests:write",
"member.manage": "members:manage",
"audit.read": "members:manage",
}
def decide(role: str | None, action: str, scopes: frozenset[str] | None = None) -> Decision:
if action not in ACTION_SCOPE:
raise ValueError(f"unknown action {action!r}")
if role is None:
return Decision(False, 404, "not_a_member", "no membership in this department")
if action not in ROLE_ACTIONS.get(role, frozenset()):
return Decision(False, 403, "role_cannot", f"{role} may not {action}")
needed = ACTION_SCOPE[action]
if scopes is not None and needed not in scopes:
return Decision(False, 403, "missing_scope", f"the token has no {needed} scope")
return Decision(True, 200, "allowed", f"{role} may {action}")

Le fichier terminé ajoute la classe de données Decision et des commentaires. decide() ne lit aucune base de données ni aucune requête, donc un test peut vérifier chaque case en quelques microsecondes.

Créez city_api/tenancy.py. Sa fonction require(action) est une dépendance FastAPI : une route qui la demande s’exécute seulement après les trois vérifications. Le cœur du code :

city_api/tenancy.py (the check)
def check(
request: Request, con: sqlite3.Connection, identity: Identity, department: str, action: str
) -> Caller:
role = role_of(con, department, identity.user_id)
decision = decide(role, action, identity.scopes)
if not decision.allowed:
# Who TRIED matters as much as who did. The department is recorded only for a
# member (403): an outsider's attempt is not written into its log.
audit.record(
request.app.state.settings.access_db_path,
action=action,
result="denied",
actor_id=identity.user_id,
actor_kind=identity.kind,
department_id=department if decision.status == 403 else None,
target=f"{request.method} {request.url.path}",
reason=decision.code,
request_id=getattr(request.state, "request_id", None),
details={"rule": decision.rule, "department_in_path": department},
)
raise AccessDenied(decision, department, action)
return Caller(identity, department, role)
def require(action: str):
"""A FastAPI dependency: the route runs only if the caller may do `action` in the
department named in the path."""
def dependency(
request: Request, department: DepartmentId, identity: CurrentIdentity, con: AccessDb
) -> Caller:
return check(request, con, identity, department, action)
return dependency

role_of() exécute un seul SELECT role FROM memberships WHERE department_id = ? AND user_id = ?, à chaque requête. Il n’y a pas de cache, donc un membre retiré perd son accès dès sa prochaine requête. Changements de cycle de vie a mesuré le coût d’un cache de 30 secondes : 30 secondes d’accès après le retrait. Copiez le reste du fichier (Caller, AccessDenied, la connexion à la base de données d’accès) depuis le projet terminé, ainsi que city_api/audit.py. audit.write() retire tout détail dont le nom évoque un secret et remplace tout ce qui ressemble à un JWT.

Pourquoi 404 pour une personne extérieure? Un code 403 dirait à Jamal « les transports existent, et vous n’en faites pas partie »; un code 404 ne lui dit rien. Hana est membre des transports et voit déjà ses demandes. Son code 403 ne révèle donc rien et explique l’échec.

La partie 5 décide qui peut entrer dans un service. Les requêtes doivent quand même lire uniquement les données de ce service. Rappelez-vous la référence de base : sans department = ?, la liste donnait à Jamal 94 lignes de transport parmi les 100 premières. Une seule condition oubliée dans une requête annule toutes les vérifications précédentes.

Chaque fonction de city_api/queries.py reçoit un argument obligatoire department, à nommer explicitement. Et where() commence toujours par lui :

city_api/queries.py (the changed parts)
def where(filters: dict, *, department: str) -> tuple[str, list]:
"""Build the WHERE clause and its parameters: the department first, then the
filters that are set."""
parts, params = ["department = ?"], [department]
for name, value in filters.items():
if value is not None:
parts.append(CONDITIONS[name])
params.append(value)
return " AND ".join(parts), params
def get_request(
con: sqlite3.Connection, request_id: int, *, department: str
) -> sqlite3.Row | None:
"""One request of this department. Another department's request is not found."""
return con.execute(
f"SELECT {COLUMNS} FROM service_requests WHERE department = ? AND id = ?",
(department, request_id),
).fetchone()

Un appel qui l’oublie échoue dans Python avant toute exécution SQL. Ainsi, get_request(con, 64246800) produit une TypeError qui nomme l’argument manquant department, et un test le vérifie. list_requests, complaint_types et daily_stats changent de la même façon : copiez le fichier queries.py terminé. Les nouveaux index de la version 2, comme (department, created_at, id), gardent la pagination par clé rapide dans un service. Il s’agit de la pagination du cours sur les API.

Le projet du cours ajoute un deuxième verrou, la sécurité au niveau des lignes de PostgreSQL (Requêtes sûres pour chaque locataire). SQLite n’en a pas. Ici, les tests doivent donc bien couvrir l’unique verrou.

Remplacez city_api/routes.py et ajoutez city_api/people.py (/v1/me, membres et audit) à partir du projet terminé. Chaque route prend son service dans le contexte de l’appelant, jamais dans les données du client :

city_api/routes.py (part)
router = APIRouter(prefix="/v1/departments/{department}", responses=ERRORS)
ReadRequests = Annotated[Caller, Depends(require("request.read"))]
WriteNotes = Annotated[Caller, Depends(require("note.write"))]
def request_of_department(con: sqlite3.Connection, request_id: int, department: str):
"""The request, if it is this department's. Another department's request gets the
same answer as an ID that does not exist: inside this department, it does not."""
row = queries.get_request(con, request_id, department=department)
if row is None:
raise ApiError(404, "not_found", f"No service request has the ID {request_id}.")
return row

add_note écrit la note et son événement d’audit dans une seule transaction. PUT .../members/{user_id} refuse de rétrograder le dernier propriétaire (409 last_owner, dernier propriétaire). Remplacez aussi city_api/models.py et contract/openapi.json (le contrat révisé de la version 2).

Redémarrez l’API dans le premier terminal avec fastapi dev. Plus de API_KEY : les paramètres viennent maintenant de .env. Gardez le fournisseur en marche et appelez l’API depuis le troisième terminal en tant que Hana :

Fenêtre de terminal
TOKEN=$(python -m scripts.login --user usr-hana --print-access-token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/v1/me
curl -s -i -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"body": "Crew sent on 4 March."}' \
http://127.0.0.1:8000/v1/departments/transport/service-requests/64246800/notes
Sortie
{"user_id":"usr-hana","kind":"person","client_id":"city-cli","scopes":["members:manage","requests:read","requests:write"],"memberships":[{"department":"parks","role":"staff"},{"department":"transport","role":"read_only"}]}
HTTP/1.1 403 Forbidden
date: Sat, 10 Oct 2026 07:35:43 GMT
server: uvicorn
content-length: 140
content-type: application/json
x-request-id: 51f50de2832a
{"error":{"code":"forbidden","message":"Your role may not do this (read_only may not note.write).","request_id":"51f50de2832a","fields":[]}}

La même note sur une demande des parcs, .../departments/parks/service-requests/64225360/notes, donne 201 avec "author_id":"usr-hana". Aux parcs, elle fait partie du personnel. Maintenant, Oliver, qui n’a aucune adhésion, demande un vrai service et un service inventé, sanitation :

Output (Oliver: transport, then sanitation; the status line and the body)
HTTP/1.1 404 Not Found
{"error":{"code":"not_found","message":"Not found.","request_id":"cf7fde715793","fields":[]}}
HTTP/1.1 404 Not Found
{"error":{"code":"not_found","message":"Not found.","request_id":"e6a07bbe8dae","fields":[]}}

Ce que cela nous apprend. /v1/me montre les adhésions de la table de l’API. La même personne reçoit 403 dans un service et 201 dans l’autre. Les deux réponses d’Oliver contiennent les mêmes octets, sauf pour request_id. Leur durée peut encore différer légèrement, comme l’a mesuré L’autorisation dans l’API. Cette conception ne cherche pas à cacher cette différence.

Vérifiez ensuite tous les mauvais jetons en une fois. scripts/token.py crée un jeton inventé pour chaque erreur ou attaque (construite pour cette étude de cas). Il l’envoie à /v1/me :

Third terminal
python -m scripts.token cases
Sortie
Made-up tokens sent to GET /v1/me (constructed for this case study):
valid 200 accepted Priya's access token, as the provider issued it
no_token 401 refused: not_signed_in no Authorization header
expired 401 refused: expired expired 5 minutes ago
wrong_audience 401 refused: wrong_audience made for another API (aud billing-api)
wrong_issuer 401 refused: wrong_issuer from another issuer
id_token 401 refused: wrong_token_type Priya's ID token sent as an access token
alg_none 401 refused: algorithm_not_allowed unsigned (alg none)
unknown_key 401 refused: unknown_key signed with a key the provider never published
wrong_key_same_kid 401 refused: bad_signature signed with another key, with the provider's kid
tampered 401 refused: bad_signature sub changed to usr-teresa after signing
missing_sub 401 refused: missing_claim no sub claim
not_a_jwt 401 refused: malformed a random string

Erreur courante

Chaque requête reçoit 503 sign_in_unavailable ou 401 wrong_issuer

503 sign_in_unavailable (connexion indisponible) : l’API n’a pas de OIDC_ISSUER. Vérifiez que .env contient la ligne, puis redémarrez fastapi dev. 401 avec X-Token-Check: wrong_issuer (mauvais émetteur) : l’émetteur du jeton et celui de .env doivent être exactement identiques. Cela vaut aussi pour localhost et 127.0.0.1. Le fournisseur d’exercice indique http://localhost:8410. Un 503 identity_provider_unavailable (fournisseur d’identité indisponible) signifie que le fournisseur n’est pas en marche : lancez python -m idp.

Partie 7 : la matrice de tests du contrôle d’accès

Section intitulée « Partie 7 : la matrice de tests du contrôle d’accès »

Dix routes, huit identités, deux services : 160 réponses. Une modification d’une route peut casser une case. Un test de quelques parcours réussis ne le voit pas. Tests négatifs a enseigné la solution : écrire la matrice entière sous forme de test. Chaque exécution vérifie ainsi chaque case.

Les tests ont besoin de jetons sans fournisseur en marche. Le fichier terminé tests/conftest.py exécute le fournisseur dans le processus de test. Il crée un client de test pour n’importe quelle personne : client_as("usr-hana"). Il construit aussi une petite base de test : 30 lignes de transport (celles du cours sur les API). Il ajoute 12 vraies lignes des parcs pour les mêmes jours. Un service oublié les mélangerait donc. Copiez-le avec tests/fixture_rows_parks.csv et les cinq fichiers de tests du cours sur les API, depuis le projet terminé. Ils appellent maintenant /v1/departments/transport/... en tant que Teresa, et leurs nombres n’ont pas changé. Dans transport, l’API répond comme avant.

Écrivez ensuite la matrice. La réponse attendue de chaque case vient de decide(), la fonction que l’API utilise aussi. Les personnes viennent de data/identity/ :

tests/test_access_matrix.py (core)
def expected(user: str, department: str, route: str) -> int:
action, *_, ok = ROUTES[route]
decision = decide(MEMBERSHIPS.get((department, user)), action)
return ok if decision.allowed else decision.status
CASES = [(u, d, r) for u in PEOPLE for d in DEPARTMENTS for r in ROUTES]
@pytest.mark.parametrize("user, department, route", CASES, ids=["|".join(c) for c in CASES])
def test_matrix(client_as, user, department, route):
response = call(client_as(user), department, route)
assert response.status_code == expected(user, department, route), response.text

ROUTES énumère les 10 routes avec leur action, méthode, chemin et corps. Deux autres tests complètent le fichier. test_another_departments_request_id utilise le chemin du service du membre avec l’identifiant d’une demande de l’autre service. Il teste 5 personnes, avec 3 demandes chacune. test_bad_identity envoie 6 identités invalides aux 10 routes : aucun jeton, jeton expiré, mauvaise audience, mauvais émetteur. Il teste aussi un jeton d’identité et un jeton sans portée. Copiez le fichier terminé pour ces tests, ainsi que test_tokens.py et test_access_control.py pour les règles individuelles. Exécutez ensuite le tout et affichez la matrice :

Fenêtre de terminal
python -m pytest
python -m scripts.matrix_report
Output (end of pytest, then the report, shortened)
============================= 326 passed in 18.19s =============================
list read notes note+ types daily membr role remove audit
usr-teresa @ transport 200 200 200 201 200 200 200 200 204 200
usr-teresa @ parks 404 404 404 404 404 404 404 404 404 404
usr-priya @ transport 200 200 200 201 200 200 200 403 403 403
...
transport-dashboards @ parks 404 404 404 404 404 404 404 404 404 404
225 of 225 checks passed. (The grid: 160 cells. The other checks: another department's request ID, 5 x 3, and 6 bad identities x 10 routes.)
Une grille de 16 lignes et 10 colonnes. Lignes : Teresa, Priya, Hana, Lena, Jamal, Marcus, Oliver et le service de tableau de bord, chacun dans transport et dans parcs. Colonnes : lister les demandes, lire une demande, lire les notes, ajouter une note, types de plaintes, nombres quotidiens, lister les membres, attribuer un rôle, retirer un membre, lire l'audit. Les propriétaires (Teresa dans transport, Lena dans parcs) reçoivent 200, 201 ou 204 dans chaque colonne. Le personnel (Priya et Hana dans leurs services, Jamal dans parcs) reçoit 2xx, sauf 403 pour attribuer un rôle, retirer un membre et lire l'audit. Les membres en lecture seule (Hana dans transport, Marcus dans parcs, le service de tableau de bord dans transport) reçoivent 2xx pour les cinq colonnes de lecture. Ils reçoivent 403 pour ajouter une note, lister les membres, attribuer un rôle, retirer un membre et lire l'audit. Chaque ligne d'une personne sans adhésion au service, et les deux lignes d'Oliver, contient 404 dans chaque colonne. Les 225 vérifications ont réussi.
La matrice de tests du contrôle d'accès, issue de la vraie exécution : chaque identité dans chaque service, sur chaque route. Vert : autorisé. Ambre 404 : pas membre. Rouge 403 : membre dont le rôle ne permet pas l'action.
Description textuelle du diagramme

Chaque ligne représente une identité dans un service; chaque colonne représente une route. Ce motif forme la matrice d’accès. Les propriétaires peuvent tout faire dans leur propre service. Le personnel peut lire, ajouter des notes et lister les membres. Les membres en lecture seule peuvent seulement lire. Toute personne sans adhésion reçoit 404 partout, même sur les routes réservées aux propriétaires. La vérification de l’adhésion s’exécute en premier. Les deux lignes de Hana diffèrent, car ses deux adhésions ont des rôles différents. Les 225 vérifications sur 225 ont réussi.

Ce que cela nous apprend. La grille est la matrice d’accès de docs/access-matrix.md, vérifiée. Une personne externe ne voit jamais de 403, et les deux lignes de Hana diffèrent dans quatre colonnes. Si une modification future ouvre une route au mauvais rôle, une case échoue et le test la nomme. Par exemple : test_matrix[usr-hana|transport|add a note].

Un fournisseur simulé peut cacher de vrais problèmes. L’exécution du cours avec Keycloak en a révélé deux, dans Configuration du fournisseur. Exécutez donc la même API avec Keycloak (Apache-2.0), gratuitement, dans Docker, puis supprimez-le. optional/keycloak/README.md contient les mêmes étapes :

  1. Lancez Keycloak 26.8.0. Choisissez votre propre mot de passe d’administration :

    Fenêtre de terminal
    docker run -d --name city-keycloak -p 127.0.0.1:8480:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=choose-one quay.io/keycloak/keycloak:26.8.0 start-dev
  2. Environ une minute plus tard, créez le domaine city, les portées et un mappage d’audience pour city-requests-api. Créez aussi l’application city-cli et une personne inventée, teresa :

    Fenêtre de terminal
    KEYCLOAK_ADMIN_PASSWORD=choose-one python optional/keycloak/setup.py
    Sortie
    Keycloak is ready. Put these lines in .env (instead of the practice provider's):
    OIDC_ISSUER=http://127.0.0.1:8480/realms/city
    OIDC_CLIENT_ID=city-cli
    ACCESS_TOKEN_TYPES=JWT
    Sign in as teresa with the password <printed once> (made up; shown once).
    Keycloak's user ID for teresa (the token's sub): 3f5e5bb0-e331-47db-9baf-6fa1e61f573e
    Make her the owner of transport:
    python -m scripts.init_access grant --department transport --user 3f5e5bb0-e331-47db-9baf-6fa1e61f573e --role owner --name 'Teresa (Keycloak)'
  3. Mettez les deux premières lignes dans .env, à la place du OIDC_ISSUER du fournisseur d’exercice. Laissez ACCESS_TOKEN_TYPES tel quel pour l’instant. Redémarrez fastapi dev.

  4. Connectez-vous dans votre navigateur : python -m scripts.login --browser --name teresa-kc. Connectez-vous en tant que teresa sur la page de Keycloak. Le script attend sur http://127.0.0.1:8775/callback. C’est une redirection de bouclage. Un programme en ligne de commande n’a pas sa propre adresse Web. Il lance donc un petit serveur Web sur votre ordinateur pour une seule requête. Le fournisseur y envoie le code.

Sortie (extrait)
2. The provider sent the browser back with a one-time code; the state matches.
3. The code and the PKCE verifier were swapped for tokens (POST /token):
200: token_type Bearer, expires_in 600 s
access_token eyJhbGciOiJSUzI1NiIsInR5...(1159 characters)
access_token (decoded, not checked):
header {"alg": "RS256", "typ": "JWT", "kid": "FXrCCgHvyrCNp6CC707HRjilq6lf67QBk9YVn4QvbFk"}
claims {
"iss": "http://127.0.0.1:8480/realms/city",
"aud": "city-requests-api",
"sub": "3f5e5bb0-e331-47db-9baf-6fa1e61f573e",
"azp": "city-cli",
"scope": "openid requests:read requests:write email profile offline_access members:manage",
...
}

Appelez /v1/me avec ce jeton. Définissez ensuite ACCESS_TOKEN_TYPES=JWT dans .env, redémarrez l’API et appelez-la de nouveau :

Output (/v1/me, before and after ACCESS_TOKEN_TYPES=JWT)
HTTP/1.1 401 Unauthorized
x-token-check: wrong_token_type
{"error":{"code":"invalid_token","message":"This is not an access token (an ID token is for the application, not the API).","request_id":"b1525a9d0b47","fields":[]}}
{"user_id":"3f5e5bb0-e331-47db-9baf-6fa1e61f573e","kind":"person","client_id":"city-cli","scopes":["email","members:manage","offline_access","openid","profile","requests:read","requests:write"],"memberships":[]}

Ce que cela nous apprend. Le jeton d’accès de Keycloak indique typ: JWT, pas at+jwt. La vérification du type a donc refusé un vrai jeton d’accès. Avec ACCESS_TOKEN_TYPES=JWT, il passe. Le jeton d’identité de Keycloak reste refusé, cette fois par la vérification de l’audience (X-Token-Check: wrong_audience, mauvaise audience). Teresa a maintenant un nouveau sub, un UUID, et aucune adhésion. Transport lui répond 404 jusqu’à ce que la commande grant (accorder) de l’étape 2 la rende propriétaire. La liste répond alors 200, et parcs répond toujours 404.

Quelqu’un doit créer le premier propriétaire : ici, un opérateur sur le serveur, avec un événement d’audit. Ensuite, les propriétaires ajoutent des personnes par l’API. Supprimez Keycloak à la fin (docker rm -f city-keycloak) et remettez les lignes d’exercice dans .env.

De nombreux bureaux municipaux se connectent avec Microsoft Entra ID. docs/entra-id.md adapte cette conception à ce fournisseur à partir de la documentation de Microsoft. Il n’a pas été exécuté : un annuaire Entra ID exige un compte Microsoft, et cette étude de cas n’en exige aucun. Les paramètres qui changent :

Paramètre Fournisseur d’exercice Keycloak (testé) Entra ID (non testé)
OIDC_ISSUER http://localhost:8410 http://127.0.0.1:8480/realms/city https://login.microsoftonline.com/<directory ID>/v2.0
OIDC_AUDIENCE city-requests-api city-requests-api (mappage d’audience) l’identifiant d’application (client) de l’API, un GUID
ACCESS_TOKEN_TYPES at+jwt JWT JWT
USER_ID_CLAIM sub sub oid

Dans Entra ID, le sub d’une même personne diffère d’une application à l’autre; oid ne change pas. Les adhésions utilisent donc oid. Entra ID appelle aussi l’annuaire entier un « locataire ». La ville a un locataire Entra ID et deux services dans la table de l’API.

Les deux versions répondent aux mêmes cinq questions, sur les mêmes données. La mesure est aussi la même : les tentatives qui ont obtenu ce qu’elles ne devaient pas obtenir.

Cinq tentatives, chacune avec la réponse de la version de départ et celle de l'API terminée. Jamal des parcs lit la demande de transport 64246800 : départ 200 Street Sign - Missing (panneau de rue manquant); terminé 404 Not found (introuvable). Jamal liste les demandes : départ, 94 des 100 premières sont de transport; terminé, uniquement des demandes des parcs, 0 de transport. Lena ouvre le menu des types de plaintes : départ, 31 des 42 types sont de transport; terminé, 11 types des parcs. Les nombres de transport pour le 3 mars 2025 : départ, 915 demandes ouvertes, dont 163 des parcs; terminé, 752 demandes ouvertes. Oliver a quitté l'équipe : départ, la clé fonctionne encore, 200; terminé, 404, retrait effectif à sa prochaine requête. Départ : 5 tentatives sur 5 ont obtenu ce qu'elles ne devaient pas obtenir. Terminé : les cinq mêmes refusées, et 0 sur 10 tentatives plus difficiles.
Les cinq tentatives de la version de départ, soumises aux deux versions avec les mêmes données. Les réponses de l'API terminée proviennent du jeton propre à chaque personne.
Description textuelle du diagramme

À gauche, la tentative; au milieu, la réponse de la version de départ à clé partagée. À droite, la réponse de l’API terminée quand chaque personne utilisait son propre jeton. La version de départ a échoué aux cinq tentatives : deux fuites, deux nombres erronés et une clé impossible à retirer individuellement. L’API terminée a refusé les cinq. Son propre script de tentatives en ajoute de plus difficiles, et l’API a refusé les dix.

La commande python -m scripts.attempts du projet terminé envoie dix tentatives plus difficiles à l’API en marche, avec de vrais jetons :

Output of python -m scripts.attempts
refused Jamal (parks) reads a transport request 404 not_found
refused Jamal asks for it through his own department 404 not_found
refused Jamal adds the header X-Department: transport 404 not_found
refused Oliver (no membership) lists transport 404 not_found
refused Hana (read_only in transport) adds a note 403 forbidden
refused The dashboard service adds a note 403 forbidden
refused Priya's ID token as an access token 401 invalid_token (wrong_token_type)
refused An expired token (constructed) 401 invalid_token (expired)
refused A token for another API (constructed) 401 invalid_token (wrong_audience)
refused The old shared key X-API-Key 401 not_signed_in
0 of 10 attempts got what they should not get.

Quel était le coût? Une requête de liste de 50 demandes prenait une durée médiane de 1,08 ms dans la version de départ. Elle prenait 1,50 ms dans l’API terminée. La différence vient de la vérification du jeton avec les clés en cache, d’une requête d’adhésion et d’une condition supplémentaire. Ce sont les médianes de 5 passages de 200 requêtes chacun, sur un Apple M4 utilisé simultanément pour d’autres tâches. Les deux versions fonctionnaient avec fastapi run. Vos nombres différeront; l’important est l’écart, inférieur à une demi-milliseconde.

Version de départ (clé partagée) Version terminée (jeton + adhésion)
Tentatives qui ont obtenu ce qu’elles ne devaient pas obtenir 5 sur 5 0 sur 5, et 0 sur 10 plus difficiles
Identité dans le journal « une clé » l’identifiant de la personne, user=usr-hana
Retirer une personne changer la clé pour tout le monde un DELETE, effectif à la prochaine requête
Requête de liste, médiane 1,08 ms 1,50 ms
Tests 67 326 (225 dans la matrice)

Une dernière vérification porte sur un cas qu’aucun test n’a prévu : Oliver rejoint transport, change de rôle, puis quitte. Il utilise un seul jeton d’accès pour tout cela. Teresa le gère par l’API, avec des appels curl comme dans la partie 6. La sortie ci-dessous vient de la vraie exécution, avec une ligne par appel :

Output (summarised: who, the call, the answer)
Oliver GET /v1/me memberships: []
Oliver GET .../transport/service-requests 404
Teresa PUT .../transport/members/usr-oliver read_only 200 (the member list, with Oliver)
Oliver GET .../transport/service-requests 200
Oliver POST .../service-requests/64246800/notes 403 forbidden: read_only may not note.write
Teresa PUT .../transport/members/usr-oliver staff 200
Oliver POST .../service-requests/64246800/notes 201 {"note_id":2,"author_id":"usr-oliver",...}
Teresa DELETE .../transport/members/usr-oliver 204
Oliver GET .../transport/service-requests 404 (the same token as before)
Teresa PUT .../transport/members/usr-teresa staff 409 last_owner

Teresa lit ensuite l’audit du service, GET .../transport/audit?limit=6. L’événement le plus récent apparaît en premier :

Output (shortened to one line per event)
28 usr-teresa member.remove user usr-oliver allowed
27 usr-oliver note.create service-request 64246800 allowed
26 usr-teresa member.change_role user usr-oliver allowed
25 usr-oliver note.write POST /v1/departments/transport/service-requests/... denied role_cannot
24 usr-teresa member.add user usr-oliver allowed
9 transport-dashboards note.write POST /v1/departments/transport/service-requests/... denied role_cannot

Ce que cela nous apprend. Chaque changement a pris effet à la requête suivante d’Oliver, avec le même jeton. L’API lit l’adhésion chaque fois. Son premier 404, comme personne externe, n’est pas dans le journal de transport. L’audit enregistre la tentative d’une personne externe sans le service. Sa note refusée y figure, car il était alors membre. L’événement 9 est la tentative du service de tableau de bord dans le script de tentatives. Aucun événement ne contient de jeton ni le texte d’une note. python -m scripts.audit --check a signalé 0 with a token or a secret (0 avec un jeton ou un secret).

  • Le jeton dit qui; l’API dit où et quoi. Garder les rôles hors du jeton a simplifié les doubles rôles (Hana) et le retrait immédiat (Oliver).
  • Le plus grand risque était discret, pas évident. Les pires échecs de la référence de base étaient des nombres erronés, pas des pages d’erreur. Des fonctions limitées au service et une base de test couvrant les deux services aux mêmes dates les détectent.
  • Un verrou, pas deux. SQLite n’a pas de sécurité au niveau des lignes. La base de données ne bloque donc pas un bogue dans queries.py. Passer à PostgreSQL, comme dans le projet du cours, ajouterait ce deuxième verrou.
  • La matrice prouve les règles, pas leur pertinence. Lena et Teresa doivent réviser docs/access-matrix.md lui-même.
  • Le fournisseur simulé a caché une vraie différence. La vérification du type refusait les vrais jetons d’accès de Keycloak jusqu’au changement d’un paramètre. Testez avec un vrai fournisseur avant la mise en production.
  • Non couvert : une interface Web avec sessions (Cycle de vie d’une session), les invitations, l’accès de soutien temporaire pour l’informatique municipale et les limites de débit par personne.
  • Uniquement votre propre API d’exercice. Chaque tentative de cette étude de cas cible l’API sur votre ordinateur, avec des personnes inventées. Ne testez jamais l’accès au système d’autrui sans autorisation écrite.
  • Les données concernent les signalements du public. Elles n’ont aucun nom d’appelant, mais les coordonnées indiquent des rues près des domiciles. Ne les joignez pas à d’autres données pour découvrir qui a signalé quoi. Réservez les notes aux faits professionnels, jamais aux remarques sur les appelants.
  • L’accès n’est pas une finalité. Un membre peut lire une demande; cela ne rend pas chaque utilisation équitable. Un tableau de bord qui classe les quartiers par nombre de demandes mesure encore qui fait des signalements. L’étude de cas du cours sur les API l’avait souligné.
  • Gardez les secrets hors des fichiers partagés. .env et .idp/ sont dans .gitignore. La configuration de Keycloak affiche ses mots de passe une seule fois. Le mode de développement de Keycloak ne convient jamais à de vraies personnes.

Ce projet montre ce que les employeurs demandent pour toute API d’équipe. Il comprend une connexion standard, des rôles par organisation, des requêtes sûres, une matrice d’accès testée et une piste d’audit. Il montre aussi votre jugement : un coût mesuré et des limites clairement nommées.

Quoi mettre dans le dépôt : le dossier terminé, avec README.md (la conception et les instructions d’exécution), docs/access-matrix.md, docs/entra-id.md, DATASET.md, les tests et optional/keycloak/. Conservez LICENSE (MIT) et l’attribution du dossier idp/ copié. N’incluez jamais .env, .idp/, .tokens/ ni les bases de données dans un commit (enregistrement des modifications).

Comment le décrire en entrevue, en deux phrases. « J’ai ajouté la connexion OpenID Connect et des rôles par service à une API FastAPI contenant 158 000 demandes réelles de New York 311. Une matrice d’accès de 225 vérifications valide chaque identité, service et route, et l’API fonctionne avec Keycloak après un seul réglage. »

Allez plus loin : chaque option apporte un changement principal.

  • PostgreSQL avec la sécurité au niveau des lignes, pour un deuxième verrou, comme dans Requêtes sûres pour chaque locataire.
  • Des invitations avec une expiration, au lieu de PUT par identifiant utilisateur, comme dans Changements de cycle de vie.
  • Un rôle de lecture pour toute la ville, destiné au bureau du maire, avec ses propres lignes dans la matrice.
  • Entra ID pour de vrai, si votre organisation possède un annuaire de test : suivez docs/entra-id.md et notez les différences.

L’autre étude de cas de ce cours, Auditer le contrôle d’accès d’une application multilocataire libre, applique la matrice à un logiciel écrit par quelqu’un d’autre.

Vérifiez votre compréhension

Lena propose de mettre le service et le rôle de chaque personne dans le jeton d'accès pour éviter la requête d'adhésion. Quel problème cela pose-t-il pour Hana, et pour Oliver quand Teresa le retire?

Afficher la réponse

Hana a deux rôles : lecture seule dans les transports et membre du personnel dans les parcs. Une seule revendication « rôle » ne peut pas exprimer les deux. Et l’API ferait confiance au jeton pour une décision qui revient aux propriétaires du service. Le temps pose un problème encore plus grave. Ici, un jeton dure 10 minutes. Quand Teresa retire Oliver, son jeton indiquerait encore « transports, personnel » jusqu’à son expiration, et l’API le laisserait entrer. L’API lit l’adhésion dans sa table à chaque requête. Sa requête suivante a donc reçu 404, avec le même jeton. Le jeton répond seulement à « qui »; la table répond à « où » et à « quoi ».

Vérifiez votre compréhension

Jamal (membre du personnel des parcs) demande la demande de service 64246800 des transports via /v1/departments/parks/... et via /v1/departments/transport/.... Que reçoit-il chaque fois, et pourquoi les deux réponses diffèrent-elles?

Afficher la réponse

Par les parcs, il est membre, donc les vérifications 1 à 3 réussissent. La requête contient department = 'parks' et ne trouve rien. Il reçoit 404 No service request has the ID 64246800. (aucune demande de service ne porte l’identifiant 64246800). C’est la même réponse que pour un identifiant qui n’existe nulle part. Par les transports, il n’a aucune adhésion. La vérification 2 l’arrête donc avec 404 Not found. (introuvable), la même réponse que pour un service inventé. Aucune réponse ne lui indique que la demande ou le service existe. Les messages diffèrent parce qu’il est membre des parcs : il peut savoir que les parcs n’ont pas cette demande. Il ne peut rien apprendre sur les transports.

Vérifiez votre compréhension

Les jetons de Keycloak échouaient avec wrong_token_type jusqu'au réglage ACCESS_TOKEN_TYPES=JWT. Ce réglage laisse-t-il passer un jeton d'identité? Appuyez-vous sur l'exécution de la partie 8.

Afficher la réponse

Non. Avec ACCESS_TOKEN_TYPES=JWT, la vérification du type ne peut pas distinguer les jetons d’accès de Keycloak de ses jetons d’identité. Les deux indiquent typ: JWT. Mais la vérification de l’audience le peut encore. Un jeton d’accès pour cette API contient aud: city-requests-api, grâce au mappage de l’audience. L’audience d’un jeton d’identité est l’application qui a demandé la connexion, city-cli. Pendant l’exécution, le jeton d’identité de Keycloak a reçu 401 avec X-Token-Check: wrong_audience (audience incorrecte). L’audience est la vérification essentielle. Le type est une vérification supplémentaire peu coûteuse, qui fonctionne seulement avec les fournisseurs qui définissent at+jwt.

Récapitulatif

  • Une clé partagée ne peut répondre à qui, où ou quoi : la référence de base a échoué aux 5 tentatives sur 5. Dans deux cas, elle a renvoyé des nombres incorrects sans signaler d’erreur.
  • Le jeton indique qui. Le service se trouve dans le chemin. Le rôle vient de la table d’adhésions de l’API, lue à chaque requête.
  • L’ordre des vérifications détermine les réponses. 401 : aucun jeton valide. 404 : aucune adhésion. 403 : rôle ou portée insuffisants. 404 : demande d’un autre service.
  • Les fonctions de requête limitées à un service transforment l’oubli d’un WHERE en erreur Python. Une base de données de test contenant les deux services aux mêmes dates rend une fuite visible.
  • Une matrice de tests construite à partir du même decide() vérifie chaque identité, service et route : 225 sur 225.
  • Un vrai fournisseur présente de petites différences : Keycloak nécessitait ACCESS_TOKEN_TYPES=JWT, un nouveau sub et un premier propriétaire. Entra ID nécessite oid et une audience GUID.