Aller au contenu
English

Utilisateurs et organisations

  • Leçon 2
  • 55 min
  • Module 1 : Bases de l'identité et des autorisations

Ce que vous saurez faire

Enregistrez dans PostgreSQL qui appartient à quelle organisation, et avec quel rôle. Ajoutez Bramble Books comme deuxième organisation. Notez ce que chaque rôle peut faire : c’est la matrice d’accès.

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

Ce que vous devez savoir

Votre projet

  • ~/projects/ticket-api de la dernière leçon (l’instantané start), avec PostgreSQL et Azurite en marche et l’environnement virtuel actif.

Cette leçon ajoute deux migrations, les données de la deuxième boutique, les personnes, ticket_api/access.py et son script. Après cette leçon, votre projet correspond à l’instantané end-of-m01.

Imaginez une personne qui appartient à deux clubs. Au club d’échecs, elle est trésorière : elle peut payer les factures et modifier la liste des membres. Au club de course, elle est membre ordinaire : elle peut participer aux courses, mais pas dépenser l’argent du club.

C’est la même personne, avec un seul nom et un seul visage. Ce qu’elle peut faire dépend du club. Ses pouvoirs ne figurent donc pas sur sa carte d’identité. Chaque club les inscrit dans sa propre liste de membres, à côté de son nom.

Cette liste comporte trois parties : la personne, le club et son rôle dans ce club. L’API de billets de soutien a besoin d’exactement la même liste.

La première idée d’Amira est d’ajouter une colonne role à une table de personnes. Chaque personne a une ligne :

user_id name role
usr-grace Grace propriétaire
usr-sam Sam personnel
usr-camille Camille personnel? lecture seule?

Cette table est un cas construit : elle ne fait pas partie du projet. Elle ne fonctionne plus pour Camille. Camille fait partie du personnel chez Larkfield et a un accès en lecture seule chez Bramble Books. Une colonne contient une seule valeur. La table donne donc forcément un résultat incorrect pour l’une des deux boutiques. Si elle indique staff (personnel), Camille peut modifier les billets de Bramble. Si elle indique read_only (lecture seule), Camille ne peut pas faire son travail chez Larkfield.

Une deuxième colonne, shop, n’aide pas non plus. Camille travaille dans deux boutiques, alors une seule ligne ne peut pas nommer les deux. Et une personne sans boutique, comme Tomás, n’a aucune valeur correcte.

Le rôle est un fait sur une personne dans une organisation, pas sur la personne. Il lui faut donc sa propre table.

Le modèle comporte trois tables, une pour chaque idée :

  • Un utilisateur est une personne qui peut se connecter. La clé est un identifiant d’utilisateur stable, comme usr-camille. Ce n’est pas l’adresse courriel, car une adresse peut changer et deux services de connexion peuvent fournir la même. À partir du module 2, le fournisseur d’identité fournit l’identifiant d’utilisateur.
  • Un locataire est une organisation : larkfield ou bramble. Dans ce cours, « locataire » et « organisation » désignent la même chose.
  • Une adhésion indique qu’un utilisateur appartient à un locataire, avec un rôle. La paire (locataire, utilisateur) est la clé primaire. Une personne a donc au plus une adhésion dans chaque organisation.

Un rôle nomme un ensemble d’actions permises. Le service de soutien a trois rôles : owner (propriétaire) gère les membres et fait tout le reste. staff lit et modifie les billets, et read_only permet seulement de les lire. Décider selon les rôles porte un nom : contrôle d’accès basé sur les rôles, ou RBAC. Vous ne listez pas les autorisations de Sam une par une. Vous dites « Sam fait partie du personnel », et le rôle personnel contient la liste.

Un diagramme d'entités à quatre tables. users : user_id (clé primaire, par exemple usr-camille), name, email (coordonnée seulement, non unique), platform_role (habituellement vide). memberships : tenant_id (clé primaire et clé étrangère, par exemple larkfield), user_id (clé primaire et clé étrangère), role (owner, staff ou read_only), created_at. tenants : tenant_id (clé primaire), name, status (active ou deleting). invitations : invitation_id (clé primaire), tenant_id, email, role, code_sha256 (unique), expires_at, accepted_at. Un utilisateur a zéro adhésion ou plus. Un locataire a zéro adhésion ou plus et zéro invitation ou plus. Une note sous memberships indique que le rôle appartient à l'adhésion : un par personne par organisation.
Un utilisateur rejoint un locataire par une adhésion, et le rôle appartient à cette adhésion. Les invitations permettent d'accueillir de nouveaux membres.
Description textuelle du diagramme

Quatre tables. users contient une ligne par personne, avec l’identifiant d’utilisateur comme clé. tenants contient une ligne par organisation. memberships les relie : sa clé est la paire tenant_id et user_id, deux clés étrangères, et elle contient le role. Un utilisateur peut avoir zéro, une ou plusieurs adhésions. Un locataire peut avoir plusieurs adhésions et plusieurs invitations. Une invitation précise un locataire, une adresse courriel et un rôle, et elle expire.

La quatrième table, invitations, répond à une question pratique : comment une nouvelle personne rejoint-elle l’organisation? Un propriétaire invite une adresse courriel en précisant un rôle. La personne reçoit un code à usage unique qui expire. Lorsqu’elle l’accepte, une ligne d’adhésion apparaît. La table conserve seulement une empreinte du code (son SHA-256, expliqué dans Flux d’application). Ainsi, une copie de la base de données ne révèle pas les codes. Le module 5 utilise les invitations et en laisse une expirer.

Voici les six adhésions du cours, et deux personnes qui n’en ont aucune :

tenant_id user_id role
larkfield usr-grace propriétaire
larkfield usr-sam personnel
larkfield usr-omar lecture seule
larkfield usr-camille personnel
bramble usr-ines propriétaire
bramble usr-camille lecture seule

Camille a deux lignes : deux organisations, deux rôles. Tomás et Kwame n’ont aucune ligne. Tomás peut se connecter à partir du module 2. Mais il n’appartient à aucune boutique, alors il ne voit rien. Kwame est un administrateur de la plateforme : il exploite le service pour tout le monde. Ce n’est pas un rôle de boutique, et ce rôle seul ne donne accès aux billets d’aucune boutique. Le module 5 lui donne un accès encadré et audité.

Sept personnes à gauche et deux organisations à droite. Des lignes portant les rôles les relient. Larkfield (locataire larkfield, 200 billets T-30001 à T-30200) : Grace propriétaire, Sam personnel, Omar read_only, Camille personnel. Bramble Books (locataire bramble, 40 billets T-40001 à T-40040) : Camille read_only, Ines propriétaire. Les deux lignes de Camille sont épaisses. Tomás n'a aucune ligne : aucune adhésion, ne voit aucune boutique. Kwame n'a aucune ligne non plus : administrateur de la plateforme, aucune adhésion.
Les six adhésions. Camille en a deux, avec deux rôles différents; Tomás et Kwame n'en ont aucune.
Description textuelle du diagramme

Larkfield a quatre membres : Grace comme propriétaire, Sam et Camille comme personnel, et Omar en lecture seule. Bramble Books en a deux : Ines comme propriétaire et Camille en lecture seule. Camille est la seule personne qui a deux adhésions, et ses deux rôles diffèrent. Tomás et Kwame n’ont aucune adhésion, donc aucune ligne.

Construisez maintenant ce modèle. Les migrations et les données viennent de l’instantané end-of-m01.

  1. Arrêtez l’API si elle est en marche. Dans ~/projects/ticket-api, copiez les fichiers de cette leçon depuis l’instantané. La copie inclut aussi scripts/attempts.py, que vous exécuterez dans la prochaine leçon.

    Fenêtre de terminal
    S=../nextia-learning-labs/C23/snapshots/end-of-m01
    for d in data docs migrations scripts tests ticket_api; do cp -R "$S/$d/." "$d/"; done
    cp "$S/README.md" .
  2. Ouvrez migrations/013_tenants_and_memberships.sql dans VS Code. Repérez les quatre tables du diagramme. Repérez la règle CHECK (role IN ('owner', 'staff', 'read_only')) : la base de données refuse tout autre rôle. La dernière ligne ajoute Larkfield comme premier locataire.

  3. Appliquez les migrations et chargez les deux boutiques. Utilisez --reset, car votre base de données contient déjà les données de Larkfield :

    Fenêtre de terminal
    python -m scripts.load --reset
    applied 013_tenants_and_memberships
    applied 014_tenant_columns
    Database is up to date (14 migrations).
    Loaded the small data in 1.5 s:
    larkfield bramble
    customers 40 12
    tickets 200 40
    messages 331 58
    attachments 43 14
    documents 36 3
    ai_runs 294 69
    ticket_embeddings 200 40
    57 files uploaded to the object storage.
    3 messages have no ticket: they are in orphaned_messages.
  4. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La dernière ligne est 154 passed, 1 deselected (154 réussis, 1 désélectionné) : 46 de plus qu’avant. La plupart des nouveaux tests vérifient la matrice d’accès et les deux boutiques.

Bramble Books, sa clientèle et toutes les personnes de ce cours sont fictives. Un programme, data/build_bramble.py, a créé les 40 billets de la librairie (T-40001 à T-40040, du 26 septembre au 8 octobre 2026), ses photos et ses reçus. Les adresses courriel des personnes se terminent par .example, un nom qu’aucune adresse réelle ne peut avoir.

Ouvrez psql avec docker compose exec db psql -U tickets -d tickets. D’abord, les deux locataires :

SELECT * FROM tenants;
tenant_id | name | status | created_at
-----------+---------------+--------+------------------------
larkfield | Larkfield | active | 2024-01-08 09:00:00+00
bramble | Bramble Books | active | 2026-09-15 09:00:00+00
(2 rows)

Maintenant, les adhésions de Camille, avec une jointure pour ajouter les noms :

SELECT u.name, t.name AS organization, m.role FROM memberships m JOIN users u USING (user_id) JOIN tenants t USING (tenant_id) WHERE m.user_id = 'usr-camille';
name | organization | role
---------+---------------+-----------
Camille | Larkfield | staff
Camille | Bramble Books | read_only
(2 rows)

Et les deux personnes sans adhésion. Un LEFT JOIN (jointure externe gauche) conserve un utilisateur même sans adhésion correspondante et laisse les colonnes manquantes vides :

SELECT u.name, m.tenant_id, m.role FROM users u LEFT JOIN memberships m USING (user_id) WHERE u.user_id IN ('usr-tomas', 'usr-kwame');
name | tenant_id | role
-------+-----------+------
Tomás | |
Kwame | |
(2 rows)

Les utilisateurs et les adhésions indiquent « qui appartient où ». Les données du service de soutien ont besoin de la même réponse : à qui appartient ce billet? La migration 014_tenant_columns.sql ajoute une colonne tenant_id à chaque table du service de soutien : clients, billets, messages, pièces jointes, exécutions d’IA, vecteurs et documents. Les lignes existantes reçoivent larkfield. Ensuite, la colonne n’a aucune valeur par défaut : chaque nouvelle ligne doit préciser à quelle organisation elle appartient.

SELECT tenant_id, count(*) FROM tickets GROUP BY 1;
tenant_id | count
-----------+-------
bramble | 40
larkfield | 200
(2 rows)

La migration modifie aussi les clés étrangères. Avant, un message pointait vers un billet seulement par ticket_id. Maintenant, il pointe vers la paire (tenant_id, ticket_id). Un message peut donc seulement appartenir à un billet de la même organisation. Essayez de rattacher un message de Larkfield au billet T-40001 de Bramble :

INSERT INTO messages (tenant_id, ticket_id, author, body) VALUES ('larkfield', 'T-40001', 'agent', 'x');
ERROR: insert or update on table "messages" violates foreign key constraint "messages_ticket_fk"
DETAIL: Key (tenant_id, ticket_id)=(larkfield, T-40001) is not present in table "tickets".

T-40001 existe, mais pas comme billet de Larkfield. La base de données refuse le lien, peu importe ce que fait le code de l’application. C’est un deuxième verrou derrière les vérifications de l’API. Le module 4 en ajoute d’autres.

Deux règles auparavant globales s’appliquent maintenant par organisation. Une cliente, Olga Olsen, achète dans les deux boutiques avec la même adresse courriel. Le cours sur les bases de données permettait un compte par adresse. Maintenant, c’est un compte par adresse dans chaque boutique :

SELECT tenant_id, customer_id, name, email FROM customers WHERE lower(email) = 'olga.olsen@example.com';
tenant_id | customer_id | name | email
-----------+-------------+------------+------------------------
larkfield | C-0001 | Olga Olsen | olga.olsen@example.com
bramble | B-20105 | Olga Olsen | olga.olsen@example.com
(2 rows)

Les documents suivent la même règle : les deux boutiques ont un returns-policy, et chacune a le sien. Tapez \q pour quitter psql.

Les tables indiquent le rôle de chaque personne. Notez maintenant ce que chaque rôle peut faire. Une matrice d’accès est une table avec une ligne par action et une colonne par rôle. Chaque cellule indique « permis » ou « refusé ». Le projet la conserve sous deux formes qui doivent concorder : en code dans ticket_api/access.py, et par écrit dans docs/access-matrix.md.

Fenêtre de terminal
python -m scripts.access_matrix
What each role may do (yes = allowed):
action owner staff read_only
ticket.read yes yes yes
ticket.write yes yes 403
file.read yes yes yes
file.upload yes yes 403
document.read yes yes yes
document.read_staff yes yes 403
member.read yes yes 403
member.manage yes 403 403
export.create yes yes 403
audit.read yes 403 403

Le script affiche ensuite chaque personne dans chaque boutique. Cette page montre seulement la première partie. Chaque action nomme une chose et un verbe : ticket.write signifie « ajouter un message à un billet ». Et document.read_staff signifie « lire aussi les documents réservés au personnel ».

Les cellules qui indiquent 403 sont des refus. Il existe un deuxième type de refus. Une personne sans adhésion reçoit 404 pour chaque action. La fonction qui décide est courte. Ouvrez ticket_api/access.py et repérez decide() :

if role is None:
return Decision(False, 404, "not_a_member", "no membership in this organization")
if action not in ROLE_ACTIONS.get(role, frozenset()):
return Decision(False, 403, "role_cannot", f"{role} may not {action}")

Pourquoi deux codes d’état? Un 403 Forbidden (interdit) dit « ceci existe, mais vous ne pouvez pas faire cela ». C’est acceptable pour un membre : Omar peut déjà lire les billets de Larkfield. Un refus d’écriture ne lui apprend donc rien de nouveau. Pour une personne externe, même « ceci existe » est une information. Sam ne devrait pas apprendre que Bramble Books a un billet T-40001. Une personne externe reçoit donc 404 Not Found (introuvable), comme pour quelque chose qui n’existe pas. L’autorisation dans l’API intègre cela à chaque route.

decide() n’utilise ni base de données ni requête Web. Elle prend un rôle et une action, puis renvoie une décision. Cela rend la matrice facile à lire, à tester et à afficher. Essayez-la pour trois cas :

Fenêtre de terminal
python -c "from ticket_api.access import decide; print(decide('staff', 'ticket.write')); print(decide('read_only', 'ticket.write')); print(decide(None, 'ticket.read'))"
Decision(allowed=True, status=200, code='allowed', rule='staff may ticket.write')
Decision(allowed=False, status=403, code='role_cannot', rule='read_only may not ticket.write')
Decision(allowed=False, status=404, code='not_a_member', rule='no membership in this organization')

Combinez maintenant les deux parties : une personne, une organisation et une action. L’adhésion de la personne dans cette organisation donne le rôle, et le rôle donne la réponse.

Explorer

Qui peut faire quoi, et où

Rôle dans cette organisation
staff
L’API répond
200 allowed
La règle
staff may ticket.write

Camille a le rôle staff chez Larkfield, et la règle dit « staff may ticket.write ». Permis : l’API fait l’action.

  • 200 : permis
  • 403 : un membre, mais le rôle ne le permet pas
  • 404 : pas un membre, rien n’est montré
Camille : chaque action dans les deux organisations
ActionLarkfield
staff
Bramble Books
read_only
ticket.readlister et lire les billets, trouver des billets semblables200 oui200 oui
ticket.writeajouter un message à un billet200 oui (choisi)403
file.readobtenir un lien de téléchargement pour une pièce jointe200 oui200 oui
file.uploadtéléverser une pièce jointe200 oui403
document.readchercher dans les documents d’aide publics200 oui200 oui
document.read_staffaussi les documents réservés au personnel200 oui403
member.readlister les membres de l’organisation200 oui403
member.manageinviter, changer un rôle, retirer un membre403403
export.createlancer une exportation des billets en arrière-plan200 oui403
audit.readlire les événements d’audit de l’organisation403403

Chaque réponse vient de la fonction decide() du projet, dans ticket_api/access.py, affichée par python -m scripts.access_matrix --json. « 200 » veut dire permis : une requête qui crée un enregistrement, comme un nouveau message, répond 201. Les personnes sont fictives.

Chaque décision pour 7 personnes, 2 organisations et 10 actions : 140 réponses de decide() du projet, affichées par python -m scripts.access_matrix --json.
Description textuelle de l’explorateur

L’explorateur a trois groupes de boutons : une personne, une organisation et une action. Il montre le rôle de la personne dans cette organisation, la réponse de l’API (200, 403 ou 404) et la règle appliquée. Une table montre toute la ligne de la personne dans la matrice : les dix actions dans les deux organisations. La cellule choisie est marquée.

Il commence avec Camille, Larkfield, ticket.write : Camille y fait partie du personnel. La règle dit « staff peut faire ticket.write », et la réponse est 200. Chez Bramble Books, Camille a le rôle read_only. La règle dit « read_only ne peut pas faire ticket.write », et la réponse est 403. Tomás n’a aucune adhésion dans les deux organisations, alors chaque action dans les deux organisations renvoie 404. Ines reçoit 200 pour chaque action chez Bramble Books et 404 pour chaque action chez Larkfield.

Les deux réponses de Camille à une même action viennent de deux adhésions, pas de deux personnes. C’est la raison d’être de la table des adhésions.

Ines demande deux nouvelles actions chez Bramble Books. Rédigez leurs lignes pour owner, staff et read_only, avec « oui » ou « 403 » dans chaque cellule. Donnez une raison par ligne. Notez ensuite ce que Tomás reçoit.

  • ticket.close : fermer un billet lorsque le problème est résolu.
  • settings.change : modifier le nom de la boutique et ses heures d’ouverture sur la page d’aide.

Rédigez vos lignes avant d’ouvrir la réponse dans la vérification des connaissances ci-dessous. Il n’y a aucun code à modifier dans cette tâche.

Les rôles fonctionnent bien lorsque les personnes se répartissent en quelques groupes qui font le même travail. Trois rôles et dix actions tiennent sur un écran. Une nouvelle personne a besoin d’une ligne d’adhésion, pas de dix autorisations.

Les rôles ont des limites. Une règle comme « Sam peut fermer seulement ses propres billets » dépend de l’enregistrement, pas seulement du rôle. Un rôle ne peut pas l’exprimer, alors le code doit aussi vérifier l’enregistrement. Quand ces règles se multiplient, les équipes ajoutent des règles basées sur les attributs aux rôles. Ce cours utilise seulement les rôles, plus l’organisation de chaque enregistrement.

Le modèle a aussi des coûts. Chaque requête doit lire la table des adhésions, car un rôle peut changer à tout moment. Le module 5 mesure ce qui arrive lorsque l’API conserve une ancienne réponse pendant 30 secondes. Et chaque table a besoin de son tenant_id, avec des clés et des index qui l’incluent. Vous avez vu le début de cela dans la migration 014.

Erreur courante

Le chargement s'arrête : les tables contiennent déjà des données

Symptôme : python -m scripts.load affiche seulement cette ligne, et les tests échouent parce que la colonne tenant_id manque :

The help-desk tables already have data. Run again with --reset to delete it and load again.

Cause : le programme de chargement refuse de charger des données par-dessus les données existantes. Il s’arrête avant d’appliquer les nouvelles migrations.

Solution : exécutez python -m scripts.load --reset. Cette commande applique 013 et 014, et recharge les deux boutiques.

Erreur courante

access_matrix : aucune personne avec cet identifiant

Symptôme : python -m scripts.access_matrix --user camille affiche No person with the ID camille. (aucune personne avec l’identifiant camille).

Cause : l’option attend l’identifiant d’utilisateur, pas le nom.

Solution : utilisez l’identifiant d’utilisateur de la table users : --user usr-camille.

Réinitialisation. python -m scripts.load --reset recharge les deux boutiques et les personnes. Si votre projet ne fonctionne plus, renommez-le et copiez l’instantané end-of-m01 avec votre .env, comme à l’étape 2 de la dernière leçon.

Vérifiez votre compréhension

Pourquoi le rôle est-il une colonne des adhésions plutôt que des utilisateurs? Utilisez Camille dans votre réponse.

Afficher la réponse

Un rôle indique ce qu’une personne peut faire dans une organisation. Camille est membre du personnel chez Larkfield et a un accès en lecture seule chez Bramble Books. Une colonne role (rôle) dans users (utilisateurs) contient une seule valeur. Elle serait donc incorrecte pour l’un des deux magasins. Il existe une ligne d’adhésion par personne et par organisation. Chacune des deux adhésions de Camille porte donc son propre rôle.

Vérifiez votre compréhension

Votre matrice d'accès : écrivez les lignes pour ticket.close et settings.change (owner, staff, read_only), avec une raison pour chaque ligne. Qu'obtient Tomás?

Afficher la réponse

Une bonne réponse :

Action owner staff read_only Raison
ticket.close (fermer un billet) oui oui 403 La fermeture modifie un billet. Le personnel modifie les billets; les membres en lecture seule ne font que les lire.
settings.change (modifier les paramètres) oui 403 403 Cette action modifie tout le magasin. Seul le propriétaire gère le magasin, comme avec member.manage (gérer les membres).

Tomás n’a aucune adhésion chez Bramble Books. Il obtient donc 404 pour les deux actions, comme pour toute autre action. Vos raisons peuvent différer. Vérifiez qu’aucune ligne ne permet à read_only (lecture seule) de modifier quoi que ce soit. Vérifiez aussi qu’une personne sans adhésion n’obtient jamais 403.

Vérifiez votre compréhension

Prédisez : la base de données contient les billets T-30002 (larkfield) et T-40001 (bramble). Que se passe-t-il avec INSERT INTO messages (tenant_id, ticket_id, author, body) VALUES ('bramble', 'T-30002', 'agent', 'Hi'), et pourquoi?

Afficher la réponse

La base de données refuse l’insertion avec une erreur ForeignKeyViolation (violation de clé étrangère) sur messages_ticket_fk : Key (tenant_id, ticket_id)=(bramble, T-30002) is not present in table "tickets". (La paire de clés (bramble, T-30002) est absente de la table « tickets ».) La clé étrangère utilise la paire, et T-30002 est un billet de Larkfield. Un message peut seulement appartenir à un billet de sa propre organisation.

Récapitulatif

  • Un utilisateur est une personne, identifiée par un identifiant d’utilisateur stable. Un locataire est une organisation. Une adhésion les relie et contient le rôle.
  • Camille a deux adhésions avec deux rôles : membre du personnel chez Larkfield, accès en lecture seule chez Bramble Books. Tomás et Kwame n’en ont aucune.
  • Chaque ligne du service de soutien contient un tenant_id (identifiant de locataire). Les clés étrangères utilisent (tenant_id, ticket_id) : la base de données refuse un lien entre magasins.
  • La matrice d’accès indique ce que owner (propriétaire), staff (personnel) et read_only peuvent faire. decide() renvoie 200, 403 pour un membre dont le rôle interdit l’action, et 404 pour une personne sans adhésion.
  • Votre projet correspond à end-of-m01 : 154 passed, 1 deselected (154 tests réussis, 1 test exclu).

Ensuite, vous attaquez votre propre API d’exercice avec des tentatives inventées. Vous verrez ce que la clé partagée laisse passer.

Leçon suivante : Exemples de menaces