Identité & accès
Cette page décrit l'authentification, le modèle organisationnel, les rôles et le super-admin. Deux axes distincts cohabitent :
- Rôles / permissions (cet axe, via
spatie/laravel-permission) — quelles actions un utilisateur peut effectuer. - Cloisonnement des données / tenancy — quelles lignes il peut voir. Cet axe (global scopes + policies) n'est pas encore implémenté ; voir Questions ouvertes du README.
Authentification — Laravel Sanctum
Sanctum v4 sert un modèle hybride :
- Session / cookie pour les portails web (même domaine). Les domaines stateful de développement sont déclarés dans
config/sanctum.php(localhost:3000,localhost:8000,127.0.0.1,::1). - Tokens bearer pour l'application mobile Capacitor.
Le backend répond exclusivement en JSON (les invités ne sont pas redirigés vers une page de login ; bootstrap/app.php). La durée de vie des tokens n'est pas bornée (expiration: null).
Le modèle de token est sous-classé pour l'UUID (App\Models\PersonalAccessToken) et enregistré dans AppServiceProvider::boot() via Sanctum::usePersonalAccessTokenModel().
Endpoints d'authentification (tokens mobile)
Quatre routes (routes/api.php) servent le flux par token bearer. Les deux actions d'authentification proprement dites sont regroupées sous le préfixe auth (POST /api/auth/login, POST /api/auth/logout) ; GET /api/user et PUT /api/user, qui exposent/modifient le profil courant plutôt qu'une action d'auth, restent à la racine aux côtés de GET /api/organization.
POST /api/auth/login—LoginController(invokable). Valideemail,passwordetdevice_nameviaLoginRequest, qui :- vérifie les identifiants à la main (
Hash::check, sans ouvrir de session web) ; - n'autorise que les comptes
active: un utilisateurpendingoudisabledest refusé même avec le bon mot de passe ; - applique un rate limiting (5 tentatives par couple e-mail + IP, message
auth.throttle).
En cas de succès, émet un token via
$user->createToken($device_name)et renvoie{ "token": "<plain-text>", "user": <UserResource> }. Les échecs renvoient un422avec une erreur sur le champemail.- vérifie les identifiants à la main (
GET /api/user— sousauth:sanctum. Renvoie leUserResourcede l'utilisateur courant. C'est la source de vérité du profil pour le frontend (rechargement de page, démarrage à froid de l'app mobile, deep-link) ; la copie embarquée dans la réponse de/auth/loginn'est qu'un préchargement évitant un aller- retour immédiat. Les deux exposent exactement la même forme.PUT /api/user— sousauth:sanctum. Mise à jour en libre-service du profil de l'utilisateur courant (UpdateCurrentUserController/UpdateCurrentUserRequest). Sémantique PUT/remplacement complet :first_name,last_nameetpreferred_languagesontrequired;profile_picture_id,address,address_more,city,zip,countryutilisentpresent(la clé doit toujours être présente dans la requête, sa valeur peut êtrenull).profile_picture_idest l'UUID d'une image téléversée viaPOST /api/images(bail,uuid,exists:images,id— lebailévite qu'un non-UUID atteigne la comparaison SQL sur la colonneuuidPostgreSQL et produise un 500 ; pas de contrôle de propriété : toute image existante est acceptable, les ids UUIDv7 ne sont pas devinables).emailetpasswordsont hors périmètre — aucun flux de re-vérification n'existe pour ces deux champs, ils resteraient derrière des endpoints séparés et plus prudents si jamais construits.countryest une chaîne validée par regex ISO 3166-1 alpha-2 (/^[A-Z]{2}$/), pas un enum. Renvoie le mêmeUserResourcequeGET /api/user.POST /api/auth/logout—LogoutController(invokable, sousauth:sanctum). Révoque uniquement le token de la requête courante (currentAccessToken()->delete()) et renvoie204 No Content.
Forme du UserResource
Le frontend route l'utilisateur selon ses casquettes (voir Organisations & appartenance), donc la ressource les expose toutes — c'est un indice d'UI, pas une frontière de sécurité (le backend autorise de toute façon via Gate::before + policies). Champs : id, first_name, last_name, name (accesseur calculé, first_name + last_name — conservé pour compatibilité ascendante), email, status, is_traveller, is_super_admin, preferred_language (PreferredLanguage : fr-ch | ge-ch | en-gb, défaut fr-ch), un bloc de champs de profil libre-service — profile_picture, address, address_more, city, zip, country. Les champs d'adresse sont de simples chaînes nullable ; profile_picture est null ou un objet de quatre URLs signées imgproxy — {"thumb", "medium", "large", "full"} (largeurs 200/600/1200/1920, plafonnées à la taille réelle de l'image — voir Stockage d'images, section « Variantes de tailles ») — construit depuis la relation profilePicture (users.profile_picture_id → images, chargée d'avance par les cinq contrôleurs sérialisant la ressource). Enfin organizations — la liste complète des appartenances, chaque entrée portant id, name, type (reseller | region), membership_status (invited | active) et role (manager | employee, ou null si aucun rôle scopé). Le rôle est lu d'un coup, indépendamment du team id actif, via User::organizationRoleNames() (une requête sur model_has_roles), de sorte que la sérialisation ne dépend pas du scope d'organisation courant.
L'enveloppe data des API Resources est désactivée globalement (JsonResource::withoutWrapping() dans AppServiceProvider::boot()) : une ressource de premier niveau (GET /user) et une ressource embarquée (le user de /auth/login) exposent ainsi la forme identique.
La séparation des endpoints portails / mobile par token abilities est anticipée mais non exploitée (les tokens sont émis avec l'habilité par défaut). L'auth par session/cookie pour les portails web reste à câbler.
Auto-inscription voyageur
Deux routes publiques sous le préfixe auth, toutes deux throttle:6,1 :
POST /api/auth/register—Auth\RegisterController/Auth\RegisterRequest. Payload :first_name,last_name,email,password(confirmed, min 8 — même politique que l'acceptation d'invitation) etpreferred_language, tous obligatoires. Crée un compte voyageurpending(les défauts font le reste :is_traveller = true, jamais super-admin), avec le mot de passe posé d'emblée — contrairement aux comptes invités.409si l'e-mail a déjà un compte (même convention que le signup d'organisation). Le lien d'activation réutilise la mécanique d'invitation (Invitation::issueFor(),invited_bynull — personne n'a accordé l'accès) et part par e-mail (AccountActivationNotification,lang/{locale}/registration.php, lien{frontend_url}/activate/{token}?lang=…) dans la langue choisie à l'inscription. Renvoie201+UserResource(jamais le token). Tant que le compte estpending, le login le refuse.POST /api/auth/activate/{token}—Auth\ActivateAccountController. Active le compte via le lien reçu : utilisateuractive, invitation consommée (accepted_at), et bascule aussi les appartenancesinvitedgagnées entre-temps (une organisation peut inviter la personne pendant qu'elle estpending) — les deux flux de réclamation convergent. Contrairement à l'acceptation d'invitation, aucun mot de passe n'est collecté (il existe déjà).404token inconnu,410expiré ou déjà consommé. Auditauth/account_activated.
Réinitialisation de mot de passe
Deux routes publiques (routes/api.php) s'appuient sur le Password Broker natif de Laravel (broker users, table password_reset_tokens, TTL 60 min, throttle 60 s ; config/auth.php) — aucune plomberie maison.
POST /api/auth/forgot-password—ForgotPasswordController(invokable). ValideemailviaForgotPasswordRequest, puisPassword::sendResetLink(). L'éligibilité est imposée en passantstatus => activedans les credentials : le broker filtre nativement, donc un comptependingoudisabledne reçoit jamais de lien. La réponse est toujours200vide, que l'e-mail existe ou non — anti-énumération.POST /api/auth/reset-password—ResetPasswordController(invokable). Validetoken,emailetpassword(confirmed, min 8) viaResetPasswordRequest, puisPassword::reset()(toujours filtré surstatus => active). Le callback pose le nouveau mot de passe (haché par le casthashed), régénère leremember_token, révoque tous les tokens Sanctum de l'utilisateur ($user->tokens()->delete()) et émet l'événementPasswordReset.
Les réponses ne portent aucun texte destiné à l'utilisateur : le SPA gère l'i18n et mappe les codes. reset-password renvoie 200 (succès, vide), 422 (saisie invalide, format de validation Laravel) ou 400 { "error": "invalid_reset_token" } quand le lien est inutilisable. Tous les motifs d'échec du broker (token faux ou expiré, e-mail inconnu, compte non actif) collapsent dans ce seul code pour ne jamais révéler l'existence d'un compte.
Le lien envoyé pointe vers le frontend, pas vers l'API : {config('app.frontend_url')}/reset-password?token=<token>&email=<email>&lang={fr|de|en} (lang = preferredLocale() de l'utilisateur, pour que le frontend rende la page dans sa langue). La notification ResetPasswordNotification (queued, canal mail) surcharge User::sendPasswordResetNotification() pour produire ce lien au lieu de la vue Blade par défaut ; la base d'URL est configurable via FRONTEND_URL (config/app.php). Couvert par ForgotPasswordTest et ResetPasswordTest.
Token développeur — GET /api/contract
Hors Sanctum : l'endpoint qui sert le contrat OpenAPI (voir Contrat d'API) est protégé par un token statique de développeur, pas par une session utilisateur — il alimente l'outillage de codegen du frontend, pas des humains connectés.
- Middleware
contract-token(App\Http\Middleware\EnsureContractToken) : compareAuthorization: Bearer …àconfig('app.contract_token')(CONTRACT_ACCESS_TOKEN, valeur distincte par environnement) en temps constant (hash_equals). - Fail closed : variable absente ⇒
401 { "error": "invalid_contract_token" }, comme un token faux. - Bypass en env
localuniquement, pour générer contre un backend local sans configuration. - Couvert par
ContractEndpointTest.
Organisations & appartenance
Les premières entités du domaine sont User et Organization, reliées par une table d'association user_organization exposée via le modèle pivot Membership.
Organization—id(uuid),type(OrganizationType:reseller|region),name,description(texte libre, nullable),status(OrganizationStatus:pending|approved|suspended, défautpendingen base — voir Candidature d'organisation),commission+commission_type(accord de commission courant sur les codes d'activation, défauts0/percentage— sémantique gabarit/instantané détaillée dans Base de données). Seule une organisationapprovedpeut agir via le contexte de vue ; les organisations antérieures à l'auto-inscription (dont "Travelise") ont été rétro-approuvées par migration.Membership(pivotuser_organization) — clé composite (organization_id,user_id),status(MembershipStatus:invited|active) etjoined_at. Pas de colonnerole: le pivot ne décrit que l'appartenance ; le rôle vit dans les tables spatie, scopé par organisation (voir ci-dessous).User—id(uuid),first_name,last_name(+ accesseurname="{first_name} {last_name}"pour la compatibilité ascendante),email(unique),password(nullable — posé à l'acceptation d'une invitation),status(UserStatus:pending|active|disabled),is_super_admin(bool),is_traveller(bool, défauttrue),preferred_language(PreferredLanguage, défautfr-ch), et un bloc de champs de profil libre-service :profile_picture_id(FK nullable →images,SET NULLà la suppression de l'image ; relationprofilePicture),address,address_more,city,zip,country(ISO 3166-1 alpha-2). Traits :HasApiTokens,HasRoles,HasUuids,Notifiable.
Un utilisateur porte plusieurs casquettes : il peut être voyageur (is_traveller), membre d'une ou plusieurs organisations avec un rôle qui dépend de l'organisation, et/ou super-admin. Le frontend s'appuie sur ces attributs (dont is_traveller, exposé par UserResource) pour router l'utilisateur vers le bon usage.
Endpoint GET /api/organization
GET /api/organization?id=<uuid>—ShowOrganizationController(invokable, sousauth:sanctum+resolve-viewing-context). Valideid(required,uuid), charge l'organisation (findOrFail→404si elle n'existe pas), puis vérifie le contexte de vue : un super-admin peut accéder à n'importe quelle organisation ; un membre peut accéder à son organisation courante (contexte = UUID de l'org) ; un voyageur est toujours refusé (403). En cas de succès, renvoie unOrganizationResource.
Le OrganizationResource est le pendant symétrique du UserResource : là où la ressource utilisateur embarque la liste fine de ses organisations, celle-ci renvoie une organisation et son trombinoscope. Champs : id, name, type (reseller | region), description, contact_email (e-mail de contact de l'organisation entière, optionnel), commission + commission_type (accord de commission courant), et members — chaque membre portant id, name, email, membership_status (invited | active) et role (manager | employee, ou null si aucun rôle scopé). Les rôles sont lus d'un coup, indépendamment du team id actif, via Organization::memberRoleNames() (une requête sur model_has_roles scopée à l'organisation) — l'inverse de User::organizationRoleNames().
GET /api/organizations—ListOrganizationsController(invokable, sousauth:sanctum+resolve-viewing-context), réservé au contextesuper-admin. Renvoie toutes les organisations triées par nom, en résumé (OrganizationSummaryResource:id,name,type,status— sans trombinoscope). Sert à alimenter le sélecteur d'organisation de la modale d'invitation et fait apparaître les candidaturespending.
Les deux ressources (OrganizationResource et OrganizationSummaryResource) exposent status.
PATCH /api/organizations/{id} — super-admin ou manager de l'organisation
UpdateOrganizationController (invokable, sous auth:sanctum + resolve-viewing-context, whereUuid('id') → 404 sur id malformé) met à jour le profil de l'organisation. Autorisation dans UpdateOrganizationRequest::authorize() : le contexte super-admin passe toujours ; le contexte organisation exige que l'id du chemin corresponde à l'organisation du contexte et que l'utilisateur porte le rôle manager (résolu contre la bonne organisation puisque le middleware a déjà fixé le team id spatie) — premier endpoint conditionné à un rôle. Comme pour le endpoint de statut, authorize() précède le findOrFail : hors contexte super-admin, un id inconnu donne 403 avant 404.
Sémantique PATCH partielle (sometimes) sur name (jamais nullable — colonne NOT NULL), description et contact_email (nullables) ; un corps vide est un no-op 200. status (voir le endpoint de statut) et type ne figurent pas dans les règles et ne peuvent donc pas transiter par ce endpoint (validated() ne renvoie que les champs présents et validés). commission + commission_type sont réservés au contexte super-admin (Rule::prohibitedIf — un manager ne fixe pas sa propre commission, 422) et voyagent par paire (required_with mutuel), le plafond max:100 ne s'appliquant qu'au type percentage. Renvoie 200 + OrganizationResource complet (trombinoscope inclus). L'audit est automatique via le trait Auditable (diff des seuls champs modifiés, contexte viewing_as enregistré).
Candidature d'organisation
Les organisateurs disposent d'un flux d'auto-inscription : un formulaire public crée l'organisation en pending ; un super-admin l'approuve (ce qui déclenche l'e-mail d'invitation de l'organisateur) ou la suspend. Une organisation ne peut jamais revenir à pending.
POST /api/organizations/signup — public
SignupOrganizationController (invokable), hors auth:sanctum et resolve-viewing-context (comme les routes d'invitation publiques), throttle 6,1. Payload imbriqué validé par SignupOrganizationRequest :
organization—name(le nom réservé de l'organisation par défaut est refusé :Organization::default()la retrouve par son nom) ettype(reseller|region, choisi dans le formulaire) ;organizer—email,first_name,last_name,preferred_language(langue du futur e-mail d'invitation).
En transaction : organisation pending (statut posé par affectation directe — status n'est pas fillable), utilisateur pending sans mot de passe (is_traveller à false : compte métier), membership invited et rôle manager scopé à la nouvelle organisation. Aucune invitation ni e-mail à ce stade. Renvoie 201 + OrganizationSummaryResource. Un e-mail déjà enregistré → 409 (même forme que POST /invitations ; un compte existant rejoint une organisation via le flux d'invitation, pas via le signup — un invité ne peut pas prouver qu'il possède ce compte).
PATCH /api/organizations/{id}/status — super-admin
UpdateOrganizationStatusController (invokable, sous auth:sanctum + resolve-viewing-context, whereUuid('id') → 404 sur id malformé), autorisation super-admin dans UpdateOrganizationStatusRequest::authorize(). Payload : status ∈ approved | suspended — Rule::enum(...)->only([...]) rend pending structurellement inaccessible (422). Renvoie 200 + OrganizationSummaryResource ; findOrFail → 404.
Règle de déclenchement de l'invitation (explicite et idempotente) : à toute transition vers approved depuis un autre statut, SendOrganizationApprovalInvitationsAction invite les membres de l'organisation qui attendent encore leur toute première invitation — membership invited, utilisateur pending, et aucune ligne d'invitation. Ce dernier filtre rend la ré-approbation sûre : un organisateur déjà invité par une approbation précédente (invitation ouverte ou acceptée) ou un membre invité par un super-admin ne sont jamais ré-invités. Conséquences :
pending→approved: l'organisateur reçoit l'e-mail, exactement une fois ;pending→suspended(rejet) →approved: l'e-mail part quand même ;approved→suspended→approved: aucun renvoi ;- approuver une organisation déjà
approved: idempotent, aucun e-mail.
Le jeton, son hachage, l'expiration (7 jours) et la notification (UserInvitationNotification, envoyée après commit) réutilisent la mécanique du flux d'invitation ; invited_by = le super-admin approbateur. L'acceptation passe par le flux existant (POST /invitations/{token}/accept), qui active le compte et la membership sans modification.
Application du statut
ResolveViewingContext refuse le contexte organisation (X-Viewing-As: <uuid>) avec 403 { "error": "organization_not_approved" } quand l'organisation n'est pas approved — suspendre une organisation coupe immédiatement l'accès de ses membres à tous les endpoints sous contexte, et une candidature pending ne peut pas agir (son organisateur ne peut de toute façon pas se connecter avant l'acceptation de l'invitation).
Organisation par défaut — "Travelise"
Une organisation type: reseller nommée "Travelise" (nom fixé par la constante Organization::DEFAULT_ORGANIZATION_NAME) est insérée par une migration de données (seed_default_organization), donc garantie présente dans tous les environnements, y compris la production — qui ne joue jamais les seeders (cf. Déploiement). Elle apparaît comme n'importe quelle autre organisation dans GET /api/organizations et peut être sélectionnée explicitement.
Elle sert surtout de repli pour la génération de codes d'activation (POST /api/activation-codes) et pour l'import de codes hérités (POST /api/import/activation-codes, voir Base de données) : quand organisation_id est omis dans la requête, Organization::default() résout l'organisation émettrice sur "Travelise" plutôt que de forcer l'appelant à en désigner une — utile pour les codes que la plateforme émet elle-même, hors de tout revendeur ou région partenaire.
GET /api/organizations/default—ShowDefaultOrganizationController(invokable, sousauth:sanctumseul, sans contexte de vue : l'organisation par défaut est la même pour tout le monde). Renvoie l'organisation par défaut en résumé (OrganizationSummaryResource:id,name,type), pour que le frontend n'ait pas à la retrouver par son nom.404si la ligne venait à manquer (Organization::default()→firstOrFail).GET /api/users—ListUsersController(invokable, sousauth:sanctum+resolve-viewing-context), réservé au contextesuper-admin. Renvoie tous les utilisateurs triés par nom, sérialisés enUserResource(même forme queGET /user:status,is_traveller,is_super_adminet la liste de leurs appartenances avectypeetrole). Alimente l'annuaire de la gestion des rôles ; le frontend en dérive les casquettes et le rattachement de chaque personne.
Création d'organisation — POST /api/organizations
POST /api/organizations—StoreOrganizationController(invokable, sousauth:sanctum+resolve-viewing-context), réservé au contextesuper-admin(StoreOrganizationRequest::authorize()). Payload :name(unique — contrôle par validation seulement, la colonne ne porte pas d'index unique car l'import fait du find-or-create par nom),type,description(optionnelle),contact_email(optionnel — e-mail de contact de l'organisation entière, les membres gardant chacun le leur),commission+commission_type(optionnels par paire, défauts0/percentage; plafond100pour un pourcentage), et un objetmanageridentifié par e-mail. La logique vit dansCreateOrganizationAction, qui crée l'organisation puis rattache le gestionnaire selon trois branches sur l'e-mail :- utilisateur
active— membershipactive(joined_atposé) + rôlemanagerscopé, puis notificationOrganizationManagerAssignedNotification(« vous êtes désormais gestionnaire », lien vers la page de connexion) ; - utilisateur
pending(compte importé ou invitation non acceptée) — membershipinvited+ rôle scopé, invitation créée ou rafraîchie (updateOrCreate: le token précédent est invalidé) et e-mail d'invitation classique (voir Invitation d'utilisateur). Son identité (first_name,last_name,preferred_language) n'est jamais écrasée ; - e-mail inconnu — l'utilisateur est créé
pendingsans mot de passe ; les champs d'identité dumanagerne sont requis que dans cette branche (Rule::requiredIf), puis même chemin que la branchepending.
Un compte
disabledest refusé (422). Tout se joue en une transaction ; exactement un e-mail part par requête, mis en queue après le commit. Renvoie201avec l'OrganizationResourcecomplet (lemembership_statusdu membre —activeouinvited— indique la branche prise ; le token n'apparaît jamais).- utilisateur
Rôles & permissions — spatie « teams » = organisation
spatie/laravel-permission v8 est utilisé avec sa fonctionnalité « teams », la team étant mappée sur l'organisation :
config/permission.php:'teams' => true,'column_names.team_foreign_key' => 'organization_id','column_names.model_morph_key' => 'model_uuid'.- La clé d'équipe (
organization_id) est portée par les tables spatieroles,model_has_rolesetmodel_has_permissions. - Les rôles
manageretemployeesont créés comme rôles globaux (organization_idnul) parRoleSeeder(Role::findOrCreate(), idempotent).managerest le rôle par défaut d'un membre,employeele rôle restreint ; une organisation peut compter plusieurs managers et employés. L'association à un utilisateur est en revanche scopée par organisation : la ligne demodel_has_rolesporte l'organization_idcourant. - Le scope d'organisation est fixé à l'exécution via
PermissionRegistrar::setPermissionsTeamId($organization->id). Le middlewareResolveViewingContext(voir section Contexte de vue) s'en charge automatiquement pour chaque requête portant un UUID d'organisation dans l'en-têteX-Viewing-As.
Pourquoi « teams » plutôt qu'une enum sur le pivot
- « Rôle différent selon l'organisation » est exactement le cas d'usage de la fonctionnalité teams de spatie.
- On gagne la granularité permissions (un rôle agrège des permissions) sans remodeler le schéma plus tard.
- Source de vérité unique pour le rôle (tables spatie), plutôt qu'une donnée dupliquée sur le pivot.
Points de vigilance
- Toute lecture/écriture de rôle dépend du team id courant — il est fixé par
ResolveViewingContexten début de requête et remis ànulldansterminate()(Octane-safe). - Le cache de permissions de spatie doit être pris en compte lors des changements de team au sein d'une même requête (réinitialiser la relation
roleschargée).
Contexte de vue — X-Viewing-As
Un utilisateur peut porter plusieurs casquettes simultanément (voyageur, membre de plusieurs organisations, super-admin). L'API exige que chaque requête déclare explicitement sous quelle casquette elle est effectuée, via l'en-tête X-Viewing-As. Ce mécanisme rend le contexte d'appel transparent et élimine toute ambiguïté côté backend.
Valeurs autorisées
| Valeur | Signification |
|---|---|
traveler | Accès aux données propres à l'utilisateur (voyageur) |
super-admin | Accès global en tant qu'administrateur |
<uuid-organisation> | Accès en tant que membre de cette organisation |
Middleware ResolveViewingContext
Le middleware App\Http\Middleware\ResolveViewingContext (alias resolve-viewing-context) s'exécute après auth:sanctum sur toutes les routes sensibles au contexte. Il :
- Lit l'en-tête
X-Viewing-As— absent →422 { "error": "missing_viewing_context" }. - Valide la valeur :
traveler: vérifie$user->is_traveller; sinon403 { "error": "forbidden_viewing_context" }.super-admin: vérifie$user->is_super_admin; sinon403.- UUID valide : charge l'organisation (
404si inconnue), vérifie que l'organisation estapproved(sinon403 { "error": "organization_not_approved" }— voir Candidature d'organisation), puis que l'utilisateur est membre actif (MembershipStatus::Active) ; sinon403. Une appartenanceinvitedoudisabled(membre suspendu dans l'organisation) est donc refusée. - Autre valeur →
422 { "error": "invalid_viewing_context" }.
- Stocke un objet
ViewingContext(readonly) dans$request->attributessous la cléviewing_context— accessible viaViewingContext::fromRequest($request). - Pour le contexte organisation, fixe
PermissionRegistrar::setPermissionsTeamId()au team ID correspondant ; remetnulldansterminate()(Octane-safe).
Les routes non sensibles au contexte (POST /auth/logout, GET /user, PUT /user, GET /organizations/default, POST /images) restent dans le groupe auth:sanctum seul, sans ce middleware. POST /images (upload d'images, voir Stockage d'images) est en outre le premier endpoint authentifié throttlé (throttle:30,1) : c'est le seul qui fait directement croître le stockage objet.
Côté contrat OpenAPI, chaque contrôleur sous ce middleware porte l'attribut descriptif #[App\OpenApi\AcceptsViewingContext(...)] qui déclare les contextes acceptés ; une extension Scramble en dérive la documentation de l'en-tête — voir Contrat d'API.
Matrice d'accès par endpoint
| Endpoint | traveler | super-admin | organisation |
|---|---|---|---|
GET /activation-codes (tous) | ✗ 403 | ✓ | ✗ 403 |
GET /activation-codes/export (CSV) | ✓ (ses codes) | ✓ (global) | ✓ (org seulement) |
GET /activation-codes/mine | ✓ | ✗ 403 | ✗ 403 |
GET /activation-codes/customer/:id | ✗ 403 | ✓ | ✗ 403 |
GET /activation-codes/organisation/:id | ✗ 403 | ✗ 403 | ✓ si org correspondante |
GET /activation-codes/:id | ✓ si acheteur | ✓ | ✓ si org émettrice |
PUT /activation-codes/status | ✓ status=used sur ses propres codes, sans new_expiration | ✓ | ✓ si org émettrice (tout le lot) |
POST /activation-codes | ✗ 403 | ✓ | ✗ 403 |
GET /stats/activation-codes | ✓ (ses codes) | ✓ (global) | ✓ (org seulement) |
GET /organization | ✗ 403 | ✓ (toute org) | ✓ si org correspondante |
GET /organizations | ✗ 403 | ✓ | ✗ 403 |
POST /organizations | ✗ 403 | ✓ | ✗ 403 |
PATCH /organizations/:id/status | ✗ 403 | ✓ | ✗ 403 |
PATCH /organizations/:id | ✗ 403 | ✓ | ✓ si org correspondante et rôle manager |
POST /organizations/:id/members | ✗ 403 | ✓ | ✓ si org correspondante et rôle manager |
PATCH /organizations/:id/members/:userId | ✗ 403 | ✓ | ✓ si org correspondante et rôle manager |
DELETE /organizations/:id/members/:userId | ✗ 403 | ✓ | ✓ si org correspondante et rôle manager |
GET /users | ✗ 403 | ✓ | ✗ 403 |
GET /users/by-email | ✗ 403 | ✓ | ✗ 403 |
POST /invitations | ✗ 403 | ✓ | ✗ 403 |
GET /audit-log | ✗ 403 | ✓ | ✗ 403 |
GET /tour | ✓ tours active seulement | ✓ | ✓ tours active seulement |
GET /tour/:id | ✓ si active (sinon 404) — sans les étapes | ✓ (étapes embarquées) | ✓ si active (sinon 404) — sans les étapes |
POST /tour | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/:id | ✗ 403 | ✓ | ✗ 403 |
DELETE /tour/:id | ✗ 403 | ✓ | ✗ 403 |
GET /tour/provider | ✓ approved seulement | ✓ (tous statuts) | ✓ approved seulement |
GET /tour/provider/search | ✓ approved seulement | ✓ (tous statuts) | ✓ approved seulement |
GET /tour/provider/:id | ✓ si approved (sinon 404) | ✓ | ✓ si approved (sinon 404) |
POST /tour/provider | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/provider/:id | ✗ 403 | ✓ | ✗ 403 |
GET /tour/activity | ✓ approved seulement | ✓ (tous statuts) | ✓ approved seulement |
GET /tour/activity/search | ✓ approved seulement | ✓ (tous statuts) | ✓ approved seulement |
GET /tour/activity/:id | ✓ si approved (sinon 404) | ✓ | ✓ si approved (sinon 404) |
POST /tour/activity | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/activity/:id | ✗ 403 | ✓ | ✗ 403 |
GET /tour/step | ✗ 403 | ✓ | ✗ 403 |
POST /tour/step | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/step/reorder | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/step/:stepId/routings | ✗ 403 | ✓ | ✗ 403 |
PUT /tour/step/:id | ✗ 403 | ✓ | ✗ 403 |
DELETE /tour/step/:id | ✗ 403 | ✓ | ✗ 403 |
GET /activation-codes/:id/steps | ✓ si acheteur et voyage commencé ou planifié — used_at passé (voyage en cours ou terminé) ou pre-active (autre code : 404) | ✗ 403 | ✗ 403 |
GET /activation-codes/:id/steps/:stepId | ✓ mêmes gardes (étape hors du tour du voyage : 404) | ✗ 403 | ✗ 403 |
POST /activation-codes/:id/steps/:stepId/reveal | ✓ plus étroit : voyage pre-active / active uniquement, + revealable_at atteint (sinon 422) | ✗ 403 | ✗ 403 |
Sur les lectures du domaine tour, une ressource hors visibilité répond 404 (et non 403) pour ne rien divulguer de son existence ; les scopes visibleTo() des modèles Tour, Provider et Activity sont la source de vérité. Les champs back-office (internal_notes, clés CRM / héritage des prestataires) sont retirés des réponses hors contexte super-admin. L'itinéraire (étapes nommées et alternatives) est du matériel de planification réservé au super-admin — GET /tour/step répond 403 aux autres contextes et GET /tour/{id} n'embarque ses étapes qu'en super-admin ; le voyageur lit sa vue à révélation progressive sur GET /activation-codes/{id}/steps, où une étape non révélée est réduite à id / position / revealable_at (voir Routage d'étape).
Exception assumée à la règle de visibilité : l'écran de détail d'étape (GET /activation-codes/{id}/steps/{stepId}) lit le contenu par la ligne de routage et n'applique délibérément pas Activity::visibleTo() / Provider::visibleTo(), ni le filtre des alternatives vivantes. Ce n'est pas un oubli : le voyage fait autorité sur le souvenir du voyageur, donc archiver une activité ou retirer un plan ne doit jamais effacer l'écran de qui y est déjà allé (le retrait est signalé par plan_retired, pour un affichage grisé). La garde reste entière côté accès — contexte voyageur, propriété du code, voyage commencé — et le contenu n'apparaît qu'une fois l'étape révélée. Même principe que GET /tour/{id}/things-to-bring, qui traverse les étapes du tour sans filtre de catalogue.
La garde des quatre endpoints voyageur est volontairement asymétrique. Les deux lectures s'ouvrent quand la fenêtre d'activation s'ouvre et ne se referment plus jamais : la ligne de routage est immortelle, c'est elle le souvenir du voyage, et refuser un voyage terminé n'aurait protégé aucune surprise — cela aurait effacé la mémoire (onglet « Passés » de l'espace voyageur). Les deux écritures — la révélation et la demande d'un autre plan (POST …/steps/{stepId}/next-plan) — se ferment avec le voyage : elles écriraient un routage maintenant, pour un jour déjà passé, prétendant que le voyageur a été envoyé quelque part où il n'est jamais allé. Ce qui n'a pas été révélé pendant le voyage reste donc scellé à jamais. Le prédicat de lecture est used_at au passé, jamais status === 'used' : un statut posé après coup (cancelled, reimbursed, une demande de remboursement ouverte) masque used par priorité de cascade sans dé-vivre le voyage, et un code sans duration n'est jamais active (voir Routage d'étape).
POST /organizations/signup et POST /tour/provider/signup sont publics (hors matrice, comme les routes d'invitation GET /invitations/{token} et POST /invitations/{token}/accept). Le contexte organisation n'est accordé que si l'organisation est approved (voir Candidature d'organisation).
Pour PUT /activation-codes/status (changement de statut en lot), l'autorisation est tout-ou-rien : si un seul code du lot échoue à la matrice (code d'une autre organisation, code d'un autre client), la requête entière est refusée en 403 et rien n'est modifié. Les gardes métier (code déjà annulé, déjà remboursé, etc.) sont en revanche à succès partiel : les codes éligibles sont mis à jour et les autres reviennent dans skipped avec une raison (HTTP 200).
Super-admin
Au-dessus des rôles, un drapeau super-admin confère un accès total, indépendamment des organisations et des permissions.
Colonne :
users.is_super_admin(booléen, défautfalse).Bypass d'autorisation :
AppServiceProvider::boot()enregistrephpGate::before(fn (User $user, string $ability): ?bool => $user->is_super_admin ? true : null);Un super-admin court-circuite toute vérification de Gate / Policy (retour
true) ; un utilisateur normal retombe sur la logique d'autorisation habituelle (retournull). Comportement couvert parSuperAdminGateTest.Amorçage : la commande Artisan
app:ensure-super-admincrée un super-admin s'il n'en existe aucun (idempotente : elle ne fait rien si un super-admin existe déjà). Elle accepte--first-name,--last-name,--email,--password, ou les variables d'environnementSUPER_ADMIN_FIRST_NAME,SUPER_ADMIN_LAST_NAME,SUPER_ADMIN_EMAIL,SUPER_ADMIN_PASSWORD. En mode non interactif sans mot de passe, elle en génère un fort (24 caractères) et l'affiche une seule fois. Si un utilisateur existe déjà avec l'e-mail fourni, il est promu super-admin et passéactive. Comportement couvert parEnsureSuperAdminTest.
Invitation d'utilisateur
Un super-admin (ou, pour son organisation, un manager — voir Gestion des membres d'une organisation) invite une personne à rejoindre une organisation avec un rôle scopé. Le compte est provisionné dès l'invitation (l'utilisateur apparaît pending / invited) et activé à l'acceptation, quand l'invité définit son mot de passe via un lien reçu par e-mail.
Modèle
Invitation(invitations, uuid) —user_id,invited_by(nullable),token(haché ; le clair ne vit que dans l'e-mail),expires_at,accepted_at. Une seule invitation en attente par utilisateur (updateOrCreatesuruser_id+accepted_atnul) : ré-inviter rafraîchit le token. L'émission (token CSPRNG + hachage + expiration 7 jours) est centralisée dansInvitation::issueFor(). HelpersisExpired()/isAccepted(). L'approbation d'une candidature d'organisation et l'invitation de membre par une organisation (voir Gestion des membres d'une organisation) sont d'autres chemins de création d'invitations ; l'acceptation est commune à tous les flux.users.passwordest nullable : un compte invité n'a pas de mot de passe avant l'acceptation (le login exigeactive, donc unpendingne peut pas se connecter de toute façon). C'est le pattern courant des comptes invités, et Laravel gère la comparaison d'un mot de passeNULLà l'authentification.
Endpoints
POST /api/invitations—StoreInvitationController, sousauth:sanctum, réservé au super-admin (StoreInvitationRequest::authorize()). Payload :email,first_name,last_name,preferred_language(required— l'invitant doit choisir explicitement la langue ; elle est posée telle quelle sur l'utilisateur créé et détermine aussi la langue de l'e-mail d'invitation, voir Notification & lien ci-dessous), les booléens optionnelsis_super_adminetis_traveller, etassignments— un tableau de paires{ organization_id, role }(role∈manager|employee). Les trois axes sont indépendants (un voyageur peut aussi être membre d'organisation). Une invitation doit accorder quelque chose : au moins une organisation, le voyageur, ou le super-admin (sinon422). En transaction : find-or-create de l'utilisateur (pending, sans mot de passe), pose des flagsis_super_admin/is_travellerselon la requête, puis pour chaque attribution : membershipinvited(idempotent) + rôle scopé à l'organisation (setPermissionsTeamIdpuis reset pour ne pas fuiter sous Octane). Un seul token est émis (Str::random(64), stocké haché). Refuse une organisation en double dans la requête et toute organisation dont l'utilisateur est déjà membre actif (422). Renvoie unInvitationResourceminimal (jamais le token).GET /api/invitations/{token}—ShowInvitationController, public. Valide le token et renvoie le contexte d'affichage (PendingInvitationResource: email, nom,first_name/last_name— vides pour un compte-coquille, signal pour le frontend de collecter l'identité —, organisations invitées + rôle).404si inconnu,410si expiré ou déjà consommé.POST /api/invitations/{token}/accept—AcceptInvitationController, public. Payloadpassword(confirmed, min 8) +first_name/last_name, obligatoires quand le compte a des noms vides (compte-coquille créé par une invitation de membre) — un compte déjà nommé n'est jamais renommé par un token. Pose le mot de passe (et l'identité le cas échéant), passe l'utilisateuractive, bascule ses membershipsinvited→active(joined_at), et marque l'invitationaccepted_at.204. Mêmes404/410.
Les routes d'acceptation sont publiques : le token est le secret (CSPRNG, non un UUID qui serait devinable / ordonné dans le temps). Il est stocké haché et comparé par hash, à l'image des tokens Sanctum.
Notification & lien
UserInvitationNotification (canal mail, en queue) porte le token clair et construit le lien vers le frontend : {config('app.frontend_url')}/invitation/{token}?lang={fr|de|en} (config frontend_url, variable d'environnement FRONTEND_URL). Le paramètre lang reprend preferredLocale() de l'invité pour que le frontend rende la page d'acceptation dans sa langue — même mécanique sur le lien de reset password. En développement, les e-mails partent vers Mailpit.
L'e-mail est localisé dans la langue choisie par l'invitant (preferred_language du payload). User implémente Illuminate\Contracts\Translation\HasLocalePreference (preferredLocale() ⇒ PreferredLanguage::locale()) ; Laravel applique automatiquement cette locale au rendu de la notification, sans câblage particulier côté contrôleur. PreferredLanguage::locale() fait le pont entre les codes métier et les répertoires de traduction Laravel : fr-ch → fr, ge-ch → de, en-gb → en. Les chaînes traduites vivent dans lang/{locale}/invitations.php (subject, line, action, expires). Même mécanique pour l'e-mail « vous êtes désormais gestionnaire » (OrganizationManagerAssignedNotification, lang/{locale}/organizations.php) envoyé par POST /api/organizations à un gestionnaire déjà actif, et pour l'e-mail « vous avez été ajouté(e) » (OrganizationMemberAddedNotification, clés organizations.member_added.* + libellés de rôle organizations.roles.*) envoyé par POST /api/organizations/{id}/members à un utilisateur déjà actif ; aucun endpoint n'est encore localisé.
Points de vigilance
- L'invitation d'un super-admin (drapeau global, sans organisation) n'est pas couverte par ce flux orienté organisation — c'est un chemin à part, non encore construit.
- Pas d'auto-login à l'acceptation : l'invité est redirigé vers la connexion.
- La révocation et le listing des invitations en attente ne sont pas encore exposés. Retirer un membre encore
invited(DELETE /organizations/:id/members/:userId) annule bien son siège, mais l'invitation survit : l'accepter ensuite active un compte sans appartenance.
Gestion des membres d'une organisation
Les super-admins et les managers de l'organisation gèrent son personnel via trois endpoints sous /api/organizations/{id}/members (contexte de vue super-admin ou organisation correspondante + rôle manager ; l'autorisation s'exécute avant la résolution, donc un id inconnu répond 403 avant 404 hors super-admin) :
POST /organizations/{id}/members—StoreOrganizationMemberControllerAddOrganizationMemberAction. Payload :email,preferred_languageetrole(manager|employee), tous obligatoires. Trois branches selon l'e-mail :
- inconnu — création d'un compte-coquille (
pending, noms vides complétés à l'acceptation,is_traveller = false, langue du payload), appartenanceinvited+ rôle scopé, invitation émise (UserInvitationNotification) ; - utilisateur
pending— appartenanceinvited+ rôle, invitation rafraîchie (l'ancien lien meurt), e-mail renvoyé ; - utilisateur
active— appartenanceactiveimmédiate (joined_at = now()) + rôle, e-mail informatifOrganizationMemberAddedNotificationdans sa langue (lepreferred_languagedu payload ne sert qu'à la création de compte et n'écrase jamais le réglage d'un utilisateur existant).
Refus en
422(jamais409— un utilisateur existant est ici le cas normal) : déjà membre actif, appartenancedisabled(« ré-activez-la »), compte désactivé. Renvoie201+OrganizationResource(trombinoscope à jour).PATCH /organizations/{id}/members/{userId}—UpdateOrganizationMemberController+UpdateOrganizationMemberAction. Payload :roleet/oumembership_status(active|disabled), au moins l'un des deux. Une appartenanceinvitedne change pas de statut ici (elle s'active par l'acceptation). Le basculement de statut passe par le pivotMembership(audité automatiquement) ; le changement de rôle parsyncRolesscopé (entrée d'audit expliciterole_changed).200+OrganizationResource.DELETE /organizations/{id}/members/{userId}—DeleteOrganizationMemberController+RemoveOrganizationMemberAction. Détache l'appartenance et retire les rôles scopés à l'organisation (les autres organisations du membre sont intactes).204.
Invariant du dernier manager : en contexte organisation, toute rétrogradation, désactivation ou suppression qui laisserait l'organisation sans manager actif est refusée (422) — le recensement (Organization::activeManagerIds(), une seule requête SQL joignant model_has_roles × user_organization) ne compte ni les managers invited ni les disabled. Les super-admins sont exemptés et peuvent fermer une organisation. Le partage du basculement de rôle scopé (team id posé/reset en try/finally, Octane-safe) vit dans le concern App\Actions\Concerns\SyncsScopedRole.

