Aller au contenu
English

L'autorisation dans l'API

  • Leçon 11
  • 55 min
  • Module 4 : Contrôler l'accès aux ressources

Ce que vous saurez faire

Faites répondre l’API aux trois questions pour chaque requête de billet de soutien. Qui appelle, dans quelle organisation, et que peut faire cette personne dans cette organisation? Un enregistrement d’une autre organisation ressemble alors exactement à un enregistrement qui n’existe pas.

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

Ce que vous devez savoir

Votre projet

  • ~/projects/ticket-api à l’instantané end-of-m03 : python -m pytest se termine par 193 passed, 1 deselected.
  • Le dépôt des exercices à côté du projet, dans ~/projects/nextia-learning-labs. Exécutez-y git pull pour obtenir l’instantané end-of-m04.
  • Trois terminaux, chacun avec l’environnement virtuel actif : le fournisseur d’exercice (python -m idp), l’API et un pour vos commandes.

Cette leçon copie les fichiers du module entier. Après cette étape, votre projet correspond à l’instantané end-of-m04. Les deux prochaines leçons expliquent les parties de cette même copie liées à la base de données et aux fichiers.

Imaginez un immeuble de bureaux avec dix entreprises sur dix étages. À l’entrée, la réceptionniste vérifie votre carte d’identité. Cela répond à une question : qui vous êtes.

Ensuite, l’ascenseur vous mène seulement à l’étage de votre entreprise. Votre carte ne donne pas accès aux autres étages. Dans votre entreprise, une armoire porte l’inscription « Gestionnaires seulement », et un verrou la garde fermée. Votre carte l’ouvre seulement si vous y êtes gestionnaire.

Une réceptionniste attentive protège aussi les renseignements. Un visiteur demande : « Une entreprise appelée Acme se trouve-t-elle au 7e étage? » Si le visiteur n’y travaille pas, la réceptionniste répond : « Je ne peux pas vous aider avec cette question. » La réponse reste la même, qu’Acme existe ou non. Personne ne peut donc découvrir qui loue les locaux en posant des questions.

L’API de billets est maintenant cet immeuble. Larkfield et Bramble Books sont deux entreprises sur deux étages. Dans cette leçon, l’API reçoit son ascenseur et ses armoires verrouillées.

Le problème : le jeton connaît Sam, mais pas les billets

Section intitulée « Le problème : le jeton connaît Sam, mais pas les billets »

Après le module 3, l’API peut identifier la personne qui appelle. Démarrez le fournisseur et l’API, puis demandez qui est Sam. Sous Windows, tapez curl.exe, et définissez le jeton avec $T = python -m scripts.login --user usr-sam --print-access-token.

Fenêtre de terminal
T=$(python -m scripts.login --user usr-sam --print-access-token)
curl -H "Authorization: Bearer $T" http://127.0.0.1:8000/v1/me
{"user_id":"usr-sam","kind":"user","name":"Sam","platform_role":null,"client_id":"ticket-cli",
"scopes":["members:manage","tickets:read","tickets:write"],"token_expires_at":"2026-10-10T06:38:37Z",
"memberships":[{"tenant_id":"larkfield","tenant_name":"Larkfield","role":"staff"}]}

La réponse est complète : Sam, membre du personnel, chez Larkfield. Envoyez maintenant le même jeton à une route de billets :

Fenêtre de terminal
curl -H "Authorization: Bearer $T" http://127.0.0.1:8000/v1/tickets/T-40001
{"error":{"code":"unauthorized","message":"A valid X-API-Key header is required.","request_id":"290c816753c5","fields":[]}}

Les routes de billets demandent encore la clé partagée, et rien d’autre. Avec cette clé, Sam obtient le billet de Bramble, comme dans Exemples de menaces. Les 5 tentatives sur 5 ont obtenu ce qu’elles n’auraient pas dû obtenir. L’API sait qui est Sam, mais elle n’utilise pas cette information là où elle compte.

L’idée : l’organisation est dans le chemin, et le serveur la vérifie

Section intitulée « L’idée : l’organisation est dans le chemin, et le serveur la vérifie »

Kwame écrit la règle au tableau : « Chaque requête nomme son organisation. Le serveur vérifie chaque fois que la personne qui appelle en fait partie, avant de lire quoi que ce soit. »

Toutes les routes des enregistrements d’une organisation passent donc sous le nom de cette organisation :

/v1/tickets/T-30002 before: no organization
/v1/tenants/larkfield/tickets/T-30002 now: the organization is in the path

Le chemin indique quelle organisation la personne veut utiliser. L’API décide ensuite si cette personne peut l’utiliser. Elle prend cette décision à partir de sa propre table d’adhésions, jamais à partir de la requête. Cette décision est l’autorisation : ce qu’une personne identifiée peut faire. Le serveur la prend, car c’est la seule partie que la personne qui appelle ne peut pas modifier.

L’API vérifie quatre points, dans un ordre fixe, et s’arrête au premier « non » :

  1. Qui appelle? Un jeton ou une session vérifiés, comme dans les modules 2 et 3. S’il n’y en a pas : 401.
  2. Cette personne est-elle membre de l’organisation dans le chemin? Une ligne dans memberships. S’il n’y en a pas : 404.
  3. Le rôle et les portées du jeton permettent-ils l’action? La fonction decide() de la matrice d’accès. Sinon : 403.
  4. L’enregistrement appartient-il à cette organisation? La requête SQL contient tenant_id = 'larkfield'. Si aucune ligne ne correspond : 404.
Une requête GET /v1/tenants/larkfield/tickets/T-30002 avec un jeton au porteur passe par quatre vérifications successives. 1. Qui appelle? Un jeton ou une session vérifiés; non : 401 not_signed_in, par exemple sans jeton. 2. Cette personne est-elle membre de l'organisation dans le chemin? Une ligne de memberships pour le locataire et l'utilisateur; non : 404 Not found. (introuvable), pour Tomás, pour Sam chez bramble ou pour une organisation inventée. 3. Le rôle et les portées du jeton permettent-ils l'action? decide(role, action, scopes); non : 403 forbidden (interdit), quand Omar écrit ou qu'un jeton permet seulement la lecture. 4. L'enregistrement appartient-il à cette organisation? WHERE tenant_id and ticket_id; non : 404 Ticket T-40001 does not exist (le billet T-40001 n'existe pas), quand Sam demande T-40001 par larkfield. Oui aux quatre vérifications : 200 OK, Sam lit T-30002.
Les quatre vérifications de chaque route sous /v1/tenants/{tenant}/, 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 organisation dans le chemin et contient un jeton. La vérification 1 demande qui appelle; sans identité vérifiée, la réponse est 401. La vérification 2 cherche une ligne d’adhésion de cette personne dans cette organisation. Sans cette ligne, la réponse est 404 « Not found. » (introuvable), identique pour une organisation réelle ou inventée. La vérification 3 demande à la matrice d’accès si le rôle et les portées du jeton permettent l’action. Sinon, la réponse est 403. La vérification 4 est la requête SQL elle-même, qui contient l’organisation. Elle ne trouve pas un enregistrement d’une autre organisation : 404. Seule une requête qui passe les quatre vérifications obtient 200.

Pourquoi la vérification 2 précède-t-elle la vérification 3? Une personne externe n’a aucun rôle. Si l’API disait « 403 : votre rôle ne le permet pas », elle dirait aussi « cette organisation existe, et vous n’en faites pas partie ». Un 404 ne révèle rien.

La vérification 3 est la fonction pure decide() du module 1. Dans le module 1, vous lui avez donné deux entrées : un rôle et une action. L’API lui en donne une troisième : les portées du jeton. Essayez-la en Python, dans le dossier du projet :

Fenêtre de terminal
python -c "from ticket_api.access import decide; print(decide('read_only', 'ticket.write', frozenset({'tickets:read', 'tickets:write'})))"
Decision(allowed=False, status=403, code='role_cannot', rule='read_only may not ticket.write')

Modifiez une entrée à la fois pour obtenir chaque réponse de la matrice :

Rôle dans l’organisation Action Portées du jeton Réponse Règle
read_only ticket.write lecture et écriture 403 read_only ne permet pas ticket.write
staff ticket.write lecture et écriture 200 staff permet ticket.write
aucun ticket.read lecture et écriture 404 aucune adhésion à cette organisation
staff ticket.write tickets:read seulement 403 le jeton n’a pas la portée tickets:write

La dernière ligne rappelle un point du module 2. Un rôle indique ce que la personne peut faire. Une portée indique ce que la personne a permis à cette application de faire pour elle. Les deux doivent permettre l’action. Un script de production de rapports qui demande seulement tickets:read ne peut pas écrire, même pour un propriétaire.

Tous les fichiers de ce module viennent de l’instantané end-of-m04. La copie inclut aussi les fichiers des deux prochaines leçons.

  1. Arrêtez l’API avec Ctrl+C. Laissez le fournisseur fonctionner.

  2. Dans ~/projects/ticket-api, copiez les fichiers du module depuis l’instantané :

    Fenêtre de terminal
    S=../nextia-learning-labs/C23/snapshots/end-of-m04
    for d in contract migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; done
    cp "$S/.env.example" .

    Votre .env ne change pas. Les deux nouveaux paramètres, DB_ROW_SECURITY et CACHE_SECONDS, ont des valeurs par défaut.

  3. Appliquez les deux nouvelles migrations. Vos données restent, donc vous n’avez pas besoin de --reset :

    Fenêtre de terminal
    python -m ticket_api.migrate
    applied 016_tenant_indexes
    applied 017_row_level_security
    Database is up to date (17 migrations).
  4. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La dernière ligne est 218 passed, 1 deselected. Les tests du cours Bases de données et stockage pour les applications d’IA utilisent maintenant les chemins des organisations.

  5. Redémarrez l’API. La clé d’API protège encore /v1/classify et /v1/history :

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

    Sous Windows, définissez d’abord $env:API_KEY = "local-dev-key", puis exécutez fastapi dev.

Ouvrez ticket_api/tenancy.py. La fonction require(action) crée une dépendance FastAPI : une fonction que FastAPI exécute avant la route. Son cœur est court :

def check(request, db, identity, tenant_id, action) -> Caller:
with db.connection() as conn:
role = repository.membership_role(conn, tenant_id, identity.user_id)
decision = decide(role, action, identity.scopes)
if not decision.allowed:
raise AccessDenied(decision, tenant_id, action)
...

identity vient du jeton ou du témoin (cookie) de session, donc la vérification 1 a déjà eu lieu. membership_role() lit une ligne de memberships pour cette organisation et cet utilisateur. Ensuite, decide() répond.

Ouvrez maintenant ticket_api/tickets.py. Chaque route demande un Caller, et le préfixe du routeur contient l’organisation :

router = APIRouter(prefix="/v1/tenants/{tenant}/tickets", tags=["tickets"])
Read = Annotated[Caller, Depends(require("ticket.read"))]
@router.get("/{ticket_id}", ...)
def get_ticket(ticket_id: TicketId, caller: Read, db: DB) -> TicketDetail:
with db.connection(caller.tenant_id) as conn:
ticket = repository.get_ticket(conn, ticket_id, tenant_id=caller.tenant_id)

Deux détails comptent. D’abord, la route a besoin d’un Caller pour s’exécuter, et seule la vérification peut en créer un. Ensuite, l’organisation de la requête SQL vient de caller.tenant_id : l’organisation que la vérification a acceptée. Elle ne vient jamais d’un en-tête ou du corps.

Les anciennes routes /v1/tickets… et /v1/attachments… ont disparu. Une clé partagée ne peut pas identifier la personne qui appelle, donc elle ne peut pas passer la vérification 2. La clé reste seulement pour /v1/classify et /v1/history, qui ne contiennent aucun enregistrement d’organisation. Ce sont des routes entre services, comme dans le cours Créer et déployer votre première API.

Obtenez un jeton pour chaque personne. Chaque jeton dure 10 minutes. Sous Windows, utilisez $S = python -m scripts.login --user usr-sam --print-access-token, et ainsi de suite.

Fenêtre de terminal
S=$(python -m scripts.login --user usr-sam --print-access-token)
O=$(python -m scripts.login --user usr-omar --print-access-token)
TO=$(python -m scripts.login --user usr-tomas --print-access-token)
  1. Sam lit un billet de sa propre organisation :

    Fenêtre de terminal
    curl -i -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002
    HTTP/1.1 200 OK
    content-type: application/json
    cache-control: no-store
    {"ticket_id":"T-30002","customer_id":"C-0022","customer_name":"Hugo Garcia","subject":"Charged twice for order LK-182074", …}

    Certains en-têtes et la majeure partie du corps sont omis ici.

  2. Sam devine l’identifiant T-40001 de Bramble en passant par sa propre organisation :

    Fenêtre de terminal
    curl -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-40001
    {"error":{"code":"not_found","message":"Ticket T-40001 does not exist.","request_id":"d084d5ebc331","fields":[]}}

    Dans Larkfield, T-40001 n’existe pas. Demandez aussi T-49999 : le message est le même, avec l’autre identifiant.

  3. Sam essaie le chemin de Bramble :

    Fenêtre de terminal
    curl -H "Authorization: Bearer $S" http://127.0.0.1:8000/v1/tenants/bramble/tickets/T-40001
    {"error":{"code":"not_found","message":"Not found.","request_id":"8f8d37f43163","fields":[]}}

    Sam n’est pas membre de Bramble Books. La vérification 2 l’arrête avant toute requête SQL de billet.

  4. Omar, dont le rôle permet seulement la lecture, essaie d’écrire. Envoyez le même corps que dans le module 1 :

    Fenêtre de terminal
    curl -X POST -H "Authorization: Bearer $O" -H "Content-Type: application/json" -d '{"author": "agent", "body": "Refund approved."}' http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002/messages
    {"error":{"code":"forbidden","message":"Your role may not do this (read_only may not ticket.write).","request_id":"6d55bd54c0d5","fields":[]}}

    Dans PowerShell, placez le corps dans msg.json et utilisez -d "@msg.json". Le statut est 403, et l’API n’enregistre rien.

  5. Envoyez une requête sans jeton :

    Fenêtre de terminal
    curl -i http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002
    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer realm="ticket-api"
    {"error":{"code":"not_signed_in","message":"Send an access token: Authorization: Bearer <token>.","request_id":"927221212ae2","fields":[]}}
  6. Créez un jeton avec tickets:read seulement. L’utilitaire le signe avec les clés du fournisseur d’exercice, comme pour un script de production de rapports :

    Fenêtre de terminal
    R=$(python -c "from scripts.practice import token; print(token('usr-sam', 'tickets:read'))")
    curl -i -X POST -H "Authorization: Bearer $R" -H "Content-Type: application/json" -d '{"author": "agent", "body": "We refunded one payment."}' http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002/messages
    HTTP/1.1 403 Forbidden
    www-authenticate: Bearer realm="ticket-api", error="insufficient_scope"
    {"error":{"code":"insufficient_scope","message":"The token has no tickets:write scope.","request_id":"17c4a6e6318e","fields":[]}}

    Le rôle de Sam permet l’écriture. Le jeton ne la permet pas.

Le terminal de l’API affiche chaque refus avec sa raison. La raison reste dans le journal, pas dans la réponse :

INFO ticket_api: access denied: role_cannot usr-omar ticket.write in larkfield id=6d55bd54c0d5
INFO ticket_api: POST /v1/tenants/larkfield/tickets/T-30002/messages 403 2ms queries=1 id=6d55bd54c0d5

Pourquoi 404 pour les personnes externes, et 403 pour les membres

Section intitulée « Pourquoi 404 pour les personnes externes, et 403 pour les membres »

Tomás n’a aucune adhésion. Il demande Larkfield, une organisation réelle, puis acme, qui n’existe pas :

Fenêtre de terminal
curl -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002
curl -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/acme/tickets/T-30002
{"error":{"code":"not_found","message":"Not found.","request_id":"d2f7009169b7","fields":[]}}
{"error":{"code":"not_found","message":"Not found.","request_id":"c4539997ef8d","fields":[]}}

Seul le request_id diffère. Sous macOS et Linux, retirez-le et comparez les octets :

Fenêtre de terminal
curl -s -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/larkfield/tickets/T-30002 | sed 's/"request_id":"[0-9a-f]*",//' > real.txt
curl -s -H "Authorization: Bearer $TO" http://127.0.0.1:8000/v1/tenants/acme/tickets/T-30002 | sed 's/"request_id":"[0-9a-f]*",//' > made-up.txt
cmp real.txt made-up.txt && echo identical
identical

Le test test_an_outsider_gets_the_same_404_as_for_an_organization_that_does_not_exist fait la même comparaison à chaque exécution.

En haut : Tomás, sans adhésion, demande GET /v1/tenants/larkfield/tickets/T-30002 (une organisation et un billet réels), puis GET /v1/tenants/acme/tickets/T-30002 (aucune organisation de ce nom). Les deux réponses sont 404 avec le code not_found et le message Not found. (introuvable); seul le request_id diffère. En bas : Sam, membre du personnel chez Larkfield, demande par Larkfield T-40001 (un identifiant réel de Bramble Books), puis T-49999 (aucun billet de ce nom). Les deux réponses sont 404 avec le message Ticket ... does not exist. (le billet ... n'existe pas), avec l'identifiant demandé. Une ligne rouge en dessous indique les temps : les deux réponses 404 de Tomás ont pris 2,03 ms et 2,02 ms (médianes). Elles sont identiques compte tenu du bruit. Le temps de réponse peut encore révéler de l'information; ce cours ne tente pas d'éliminer cette fuite.
Ce qu'un 404 révèle à une personne externe et à un membre : rien sur une autre organisation. Seul le temps de réponse pourrait encore différer. Le cours n'a mesuré aucune différence et ne tente pas d'en éliminer une.
Description textuelle du diagramme

Les deux requêtes de Tomás visent une organisation réelle et une organisation inventée. Elles obtiennent le même corps de réponse 404, sauf l’identifiant de requête. Sam envoie deux requêtes par Larkfield : l’une avec un identifiant réel de Bramble, l’autre avec un identifiant absent. Les deux obtiennent le même message : « Ticket … does not exist. » (le billet … n’existe pas). Aucune des deux personnes n’apprend donc qu’un enregistrement ou une organisation existe. Les temps de réponse étaient identiques compte tenu du bruit : 2,03 ms et 2,02 ms dans les mesures du cours.

Une personne externe ne peut donc pas utiliser l’API pour lister les organisations ou vérifier quels identifiants de billets sont réels. Le cas d’un membre est différent. Omar est membre de Larkfield, et il peut déjà lire T-30002. Un 403 ne lui apprend rien de nouveau et lui donne une information utile : « votre rôle ne permet pas cela ». Les membres reçoivent donc 403, et les personnes externes reçoivent 404.

Il reste une limite à reconnaître. Le cours a mesuré le temps de chaque réponse : 200 requêtes, cinq passages, sur un ordinateur avec peu d’autres tâches actives. Le 404 de Tomás pour l’organisation réelle a pris une durée médiane de 2,03 ms. Pour l’organisation inventée, cette durée était de 2,02 ms. Ici, la différence se perd dans le bruit. Mais une route peut travailler davantage pour un enregistrement réel que pour un enregistrement absent, et répondre plus lentement. Une personne qui attaque peut répéter patiemment une requête de nombreuses fois et comparer les temps. C’est une fuite d’information temporelle. Certains systèmes ajoutent des délais aléatoires ou font le même travail dans tous les cas. Ce cours ne le tente pas. Il élimine la fuite dans le contenu de la réponse et précise que le temps peut encore révéler de l’information.

Le script d’Exemples de menaces comporte maintenant dix tentatives. Chaque personne envoie son propre jeton, créé avec les clés du fournisseur d’exercice.

Fenêtre de terminal
python -m scripts.attempts 2>/dev/null
Made-up attempts against your own practice API. Each person sends their own token.
1. Sam (staff at Larkfield) tries to read Bramble Books' ticket T-40001 through Larkfield's path.
GET /v1/tenants/larkfield/tickets/T-40001 -> 404 (should get: 404)
OK: not_found: Ticket T-40001 does not exist.
2. Sam (staff at Larkfield) tries to read T-40001 through Bramble Books' path.
GET /v1/tenants/bramble/tickets/T-40001 -> 404 (should get: 404)
OK: not_found: Not found.
3. Ines (owner at Bramble Books) tries to list the tickets of her own shop.
GET /v1/tenants/bramble/tickets?limit=100 -> 200 (should get: 200)
OK: 40 tickets, all bramble's
4. Omar (read-only at Larkfield) tries to add a message to T-30002 (the page hides the button; he sends the request himself).
POST /v1/tenants/larkfield/tickets/T-30002/messages -> 403 (should get: 403)
OK: forbidden: Your role may not do this (read_only may not ticket.write).
5. Tomás (no membership) tries to read Larkfield's ticket T-30002.
GET /v1/tenants/larkfield/tickets/T-30002 -> 404 (should get: 404)
OK: not_found: Not found.
6. Ines (owner at Bramble Books) tries to find tickets similar to her ticket T-40001.
GET /v1/tenants/bramble/tickets/T-40001/similar?k=5 -> 200 (should get: 200)
OK: 5 tickets, all bramble's
7. Sam (staff at Larkfield) tries to choose Bramble Books with a header (X-Tenant-ID: bramble) on Larkfield's list.
GET /v1/tenants/larkfield/tickets?limit=100 -> 200 (should get: 200)
OK: 100 tickets, all larkfield's
8. Camille (read-only at Bramble Books) tries to add a message to Bramble Books' T-40001.
POST /v1/tenants/bramble/tickets/T-40001/messages -> 403 (should get: 403)
OK: forbidden: Your role may not do this (read_only may not ticket.write).
9. Sam (staff at Larkfield) tries to get a download link for a Bramble Books receipt, with its real ID.
GET /v1/tenants/larkfield/attachments/e7e54964-6167-47fa-8c59-b0b14b2d7e4a/download -> 404 (should get: 404)
OK: not_found: Attachment e7e54964-6167-47fa-8c59-b0b14b2d7e4a does not exist.
10. Omar (read-only at Larkfield) tries to search the documents for the staff-only refund procedure.
GET /v1/tenants/larkfield/documents?q=refund approval -> 200 (should get: 200)
OK: 2 public documents, no staff-only one
0 of 10 attempts got what they should not get.

Dans PowerShell, utilisez 2>$null. Les tentatives 1 à 5 sont celles du module 1, qui avaient toutes réussi. Les tentatives 6, 9 et 10 dépendent aussi des deux prochaines leçons. Elles concernent le filtre de recherche, la recherche de fichiers et le niveau d’accès aux documents.

Utilisez une organisation dans le chemin et une vérification sur le serveur pour chaque route qui touche les enregistrements d’une organisation. C’est le choix par défaut pour toute application partagée par plusieurs organisations. Une autre conception courante prend l’organisation dans le nom d’hôte, par exemple bramble.example.com. La règle reste la même : la requête nomme l’organisation, et le serveur vérifie l’adhésion.

Le coût est faible, mais réel. Chaque requête lit maintenant d’abord une ligne d’adhésion : le journal affiche queries=1 pour la requête refusée d’Omar. Toutes les routes et tous les tests ont changé de chemin, et le contrat de l’API a une nouvelle version. Certaines équipes placent les adhésions dans le jeton pour éviter cette requête SQL. Ce cours ne le fait pas, car un membre retiré garderait son accès jusqu’à l’expiration du jeton. Changements de cycle de vie mesure exactement cela.

Erreur courante

Tomás : « Envoyez l'identifiant du locataire depuis le navigateur »

Symptôme : la page envoie X-Tenant-ID: bramble, et l’API s’en sert pour filtrer les résultats. Chaque démonstration fonctionne, car chaque utilisateur choisit sa propre organisation.

Cause : l’en-tête indique ce que l’appelant veut. N’importe quel appelant peut le modifier avec une seule option de curl. Essayez : Sam envoie cet en-tête pour la liste de Larkfield.

Fenêtre de terminal
curl -H "Authorization: Bearer $S" -H "X-Tenant-ID: bramble" "http://127.0.0.1:8000/v1/tenants/larkfield/tickets?limit=3"

Les trois éléments sont T-30200, T-30199 et T-30198 : les billets de soutien de Larkfield. L’API ignore l’en-tête.

Correction : prenez l’organisation uniquement à l’endroit que le serveur vérifie : le chemin, comparé à l’adhésion de l’appelant. ticket_api/tenancy.py ne lit jamais une organisation dans un en-tête, un paramètre de requête ou le corps. Le test test_a_tenant_header_from_the_browser_changes_nothing garantit que cela ne change pas.

Erreur courante

Tomás : « On a caché le bouton de suppression »

Symptôme : la page à /app/ affiche maintenant les billets de votre organisation. Connectez-vous en tant qu’Omar : la liste n’a pas de bouton « Reply » (répondre). Tomás pense que cela impose la règle de lecture seule.

Cause : la page se trouve dans le navigateur, et la personne contrôle le navigateur. À l’étape 4 ci-dessus, Omar a envoyé la requête sans utiliser la page.

Correction : gardez la règle dans require("ticket.write") sur le serveur. Cachez aussi le bouton pour faciliter l’utilisation : la page l’explique sous la liste. La requête d’Omar reçoit 403 dans les deux cas.

Erreur courante

Chaque requête reçoit 401 après quelques minutes

Symptôme : les commandes qui fonctionnaient reçoivent maintenant 401 avec "code":"invalid_token","message":"The token has expired." (jeton non valide, le jeton a expiré).

Cause : les jetons d’accès du fournisseur durent 10 minutes, comme dans Ce que signifie un jeton.

Correction : obtenez de nouveaux jetons avec les lignes python -m scripts.login ci-dessus.

Erreur courante

L'API répond 500 après la copie

Symptôme : chaque requête de billet reçoit 500 internal_error (erreur interne). Le terminal de l’API affiche psycopg.errors.InvalidParameterValue: role "ticket_app" does not exist (le rôle « ticket_app » n’existe pas).

Cause : le code est nouveau, mais la migration 017 n’a pas été exécutée. L’API passe au rôle ticket_app, que la migration crée. La prochaine leçon explique pourquoi.

Correction : exécutez python -m ticket_api.migrate, puis envoyez de nouveau la requête.

Réinitialisation. python -m scripts.load --reset charge de nouveau les deux boutiques et les personnes. Si votre projet ne fonctionne plus, copiez l’instantané end-of-m04 avec votre .env. Exécutez ensuite python -m scripts.load --reset et python -m idp init.

Vérifiez votre compréhension

Expliquez pourquoi l'API répond 404, et non 403, quand Tomás demande un billet de Larkfield. Pourquoi 403 convient-il à l'écriture d'Omar?

Afficher la réponse

Tomás n’est pas membre de Larkfield. Un code 403 lui indiquerait que l’organisation existe. Un code 404 donne la même réponse que pour une organisation inventée, donc il n’apprend rien. Omar est un membre qui peut déjà lire le billet, donc un code 403 ne révèle rien de nouveau. Il lui indique aussi la vraie raison : son rôle ne permet pas d’écrire.

Vérifiez votre compréhension

Prédisez : Camille fait partie du personnel de Larkfield et a un accès en lecture seule à Bramble Books. Quelle réponse reçoit chaque requête? (a) GET /v1/tenants/bramble/tickets/T-40001; (b) POST d'un message à /v1/tenants/bramble/tickets/T-40001/messages; (c) GET /v1/tenants/larkfield/tickets/T-40001; (d) toute requête sans jeton.

Afficher la réponse

(a) 200 : Camille est membre, et le rôle read_only permet de lire. (b) 403 forbidden (interdit) : read_only ne permet pas ticket.write (tentative 8). (c) 404 « Ticket T-40001 does not exist. » (le billet T-40001 n’existe pas) : Camille passe les vérifications 1 à 3 chez Larkfield, mais la requête dans Larkfield ne trouve pas ce billet. (d) 401 not_signed_in (non connecté) : la vérification 1 arrête toute requête sans identité.

Vérifiez votre compréhension

Vérification pratique : montrez que les deux réponses 404 à une personne externe sont identiques octet par octet, sauf request_id. Quelles commandes exécutez-vous, et qu'affichent-elles?

Afficher la réponse

Obtenez le jeton de Tomás, demandez /v1/tenants/larkfield/tickets/T-30002 et /v1/tenants/acme/tickets/T-30002, puis retirez request_id avec sed. cmp real.txt made-up.txt && echo identical affiche identical (identique). Les deux corps sont {"error":{"code":"not_found","message":"Not found.","fields":[]}} (introuvable).

Récapitulatif

  • Toutes les routes des enregistrements d’une organisation se trouvent sous /v1/tenants/{tenant}/. Les anciennes routes ont disparu; la clé d’API reste seulement pour /v1/classify et /v1/history.
  • Quatre vérifications, dans l’ordre : identité (401), adhésion (404), rôle et portée (403), enregistrement dans cette organisation (404).
  • Une personne externe reçoit le même code 404 pour une organisation réelle ou inventée. Un membre reçoit 403 quand son rôle ou sa portée n’accorde pas assez d’autorisations. Le temps de réponse peut encore révéler de l’information; le cours n’a mesuré aucune différence ici (2,03 et 2,02 ms).
  • L’organisation vient du chemin et de l’adhésion de l’appelant, jamais d’un en-tête. Un bouton caché n’est pas une règle.
  • python -m scripts.attempts : 0 tentative sur 10 a obtenu ce qu’elle ne devait pas obtenir.

Ensuite, vous sécurisez la partie base de données : l’organisation devient un argument obligatoire de chaque requête. La sécurité au niveau des lignes ajoute un deuxième verrou.

Leçon suivante : Requêtes sûres pour chaque locataire