Skip to content

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 / tenancyquelles 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/loginLoginController (invokable). Valide email, password et device_name via LoginRequest, qui :

    • vérifie les identifiants à la main (Hash::check, sans ouvrir de session web) ;
    • n'autorise que les comptes active : un utilisateur pending ou disabled est 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 un 422 avec une erreur sur le champ email.

  • GET /api/user — sous auth:sanctum. Renvoie le UserResource de 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/login n'est qu'un préchargement évitant un aller- retour immédiat. Les deux exposent exactement la même forme.

  • PUT /api/user — sous auth:sanctum. Mise à jour en libre-service du profil de l'utilisateur courant (UpdateCurrentUserController / UpdateCurrentUserRequest). Sémantique PUT/remplacement complet : first_name, last_name et preferred_language sont required ; profile_picture_id, address, address_more, city, zip, country utilisent present (la clé doit toujours être présente dans la requête, sa valeur peut être null). profile_picture_id est l'UUID d'une image téléversée via POST /api/images (bail, uuid, exists:images,id — le bail évite qu'un non-UUID atteigne la comparaison SQL sur la colonne uuid PostgreSQL et produise un 500 ; pas de contrôle de propriété : toute image existante est acceptable, les ids UUIDv7 ne sont pas devinables). email et password sont 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. country est une chaîne validée par regex ISO 3166-1 alpha-2 (/^[A-Z]{2}$/), pas un enum. Renvoie le même UserResource que GET /api/user.

  • POST /api/auth/logoutLogoutController (invokable, sous auth:sanctum). Révoque uniquement le token de la requête courante (currentAccessToken()->delete()) et renvoie 204 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_idimages, 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/registerAuth\RegisterController / Auth\RegisterRequest. Payload : first_name, last_name, email, password (confirmed, min 8 — même politique que l'acceptation d'invitation) et preferred_language, tous obligatoires. Crée un compte voyageur pending (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. 409 si 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_by null — 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. Renvoie 201 + UserResource (jamais le token). Tant que le compte est pending, le login le refuse.
  • POST /api/auth/activate/{token}Auth\ActivateAccountController. Active le compte via le lien reçu : utilisateur active, invitation consommée (accepted_at), et bascule aussi les appartenances invited gagnées entre-temps (une organisation peut inviter la personne pendant qu'elle est pending) — les deux flux de réclamation convergent. Contrairement à l'acceptation d'invitation, aucun mot de passe n'est collecté (il existe déjà). 404 token inconnu, 410 expiré ou déjà consommé. Audit auth / 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-passwordForgotPasswordController (invokable). Valide email via ForgotPasswordRequest, puis Password::sendResetLink(). L'éligibilité est imposée en passant status => active dans les credentials : le broker filtre nativement, donc un compte pending ou disabled ne reçoit jamais de lien. La réponse est toujours 200 vide, que l'e-mail existe ou non — anti-énumération.
  • POST /api/auth/reset-passwordResetPasswordController (invokable). Valide token, email et password (confirmed, min 8) via ResetPasswordRequest, puis Password::reset() (toujours filtré sur status => active). Le callback pose le nouveau mot de passe (haché par le cast hashed), régénère le remember_token, révoque tous les tokens Sanctum de l'utilisateur ($user->tokens()->delete()) et émet l'événement PasswordReset.

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) : compare Authorization: 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 local uniquement, 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.

  • Organizationid (uuid), type (OrganizationType : reseller | region), name, description (texte libre, nullable), status (OrganizationStatus : pending | approved | suspended, défaut pending en base — voir Candidature d'organisation), commission + commission_type (accord de commission courant sur les codes d'activation, défauts 0 / percentage — sémantique gabarit/instantané détaillée dans Base de données). Seule une organisation approved peut agir via le contexte de vue ; les organisations antérieures à l'auto-inscription (dont "Travelise") ont été rétro-approuvées par migration.
  • Membership (pivot user_organization) — clé composite (organization_id, user_id), status (MembershipStatus : invited | active) et joined_at. Pas de colonne role : le pivot ne décrit que l'appartenance ; le rôle vit dans les tables spatie, scopé par organisation (voir ci-dessous).
  • Userid (uuid), first_name, last_name (+ accesseur name = "{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éfaut true), preferred_language (PreferredLanguage, défaut fr-ch), et un bloc de champs de profil libre-service : profile_picture_id (FK nullable → images, SET NULL à la suppression de l'image ; relation profilePicture), 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, sous auth:sanctum + resolve-viewing-context). Valide id (required, uuid), charge l'organisation (findOrFail404 si 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 un OrganizationResource.

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/organizationsListOrganizationsController (invokable, sous auth:sanctum + resolve-viewing-context), réservé au contexte super-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 candidatures pending.

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 :

  • organizationname (le nom réservé de l'organisation par défaut est refusé : Organization::default() la retrouve par son nom) et type (reseller | region, choisi dans le formulaire) ;
  • organizeremail, 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 : statusapproved | suspendedRule::enum(...)->only([...]) rend pending structurellement inaccessible (422). Renvoie 200 + OrganizationSummaryResource ; findOrFail404.

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 :

  • pendingapproved : l'organisateur reçoit l'e-mail, exactement une fois ;
  • pendingsuspended (rejet) → approved : l'e-mail part quand même ;
  • approvedsuspendedapproved : 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/defaultShowDefaultOrganizationController (invokable, sous auth:sanctum seul, 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. 404 si la ligne venait à manquer (Organization::default()firstOrFail).

  • GET /api/usersListUsersController (invokable, sous auth:sanctum + resolve-viewing-context), réservé au contexte super-admin. Renvoie tous les utilisateurs triés par nom, sérialisés en UserResource (même forme que GET /user : status, is_traveller, is_super_admin et la liste de leurs appartenances avec type et role). 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/organizationsStoreOrganizationController (invokable, sous auth:sanctum + resolve-viewing-context), réservé au contexte super-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éfauts 0 / percentage ; plafond 100 pour un pourcentage), et un objet manager identifié par e-mail. La logique vit dans CreateOrganizationAction, qui crée l'organisation puis rattache le gestionnaire selon trois branches sur l'e-mail :

    • utilisateur active — membership active (joined_at posé) + rôle manager scopé, puis notification OrganizationManagerAssignedNotification (« vous êtes désormais gestionnaire », lien vers la page de connexion) ;
    • utilisateur pending (compte importé ou invitation non acceptée) — membership invited + 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éé pending sans mot de passe ; les champs d'identité du manager ne sont requis que dans cette branche (Rule::requiredIf), puis même chemin que la branche pending.

    Un compte disabled est refusé (422). Tout se joue en une transaction ; exactement un e-mail part par requête, mis en queue après le commit. Renvoie 201 avec l'OrganizationResource complet (le membership_status du membre — active ou invited — indique la branche prise ; le token n'apparaît jamais).

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 spatie roles, model_has_roles et model_has_permissions.
  • Les rôles manager et employee sont créés comme rôles globaux (organization_id nul) par RoleSeeder (Role::findOrCreate(), idempotent). manager est le rôle par défaut d'un membre, employee le 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 de model_has_roles porte l'organization_id courant.
  • Le scope d'organisation est fixé à l'exécution via PermissionRegistrar::setPermissionsTeamId($organization->id). Le middleware ResolveViewingContext (voir section Contexte de vue) s'en charge automatiquement pour chaque requête portant un UUID d'organisation dans l'en-tête X-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 ResolveViewingContext en début de requête et remis à null dans terminate() (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 roles chargé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

ValeurSignification
travelerAccès aux données propres à l'utilisateur (voyageur)
super-adminAccè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 :

  1. Lit l'en-tête X-Viewing-As — absent → 422 { "error": "missing_viewing_context" }.
  2. Valide la valeur :
    • traveler : vérifie $user->is_traveller ; sinon 403 { "error": "forbidden_viewing_context" }.
    • super-admin : vérifie $user->is_super_admin ; sinon 403.
    • UUID valide : charge l'organisation (404 si inconnue), vérifie que l'organisation est approved (sinon 403 { "error": "organization_not_approved" } — voir Candidature d'organisation), puis que l'utilisateur est membre actif (MembershipStatus::Active) ; sinon 403. Une appartenance invited ou disabled (membre suspendu dans l'organisation) est donc refusée.
    • Autre valeur → 422 { "error": "invalid_viewing_context" }.
  3. Stocke un objet ViewingContext (readonly) dans $request->attributes sous la clé viewing_context — accessible via ViewingContext::fromRequest($request).
  4. Pour le contexte organisation, fixe PermissionRegistrar::setPermissionsTeamId() au team ID correspondant ; remet null dans terminate() (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

Endpointtravelersuper-adminorganisation
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/statusstatus=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/providerapproved seulement✓ (tous statuts)approved seulement
GET /tour/provider/searchapproved 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/activityapproved seulement✓ (tous statuts)approved seulement
GET /tour/activity/searchapproved 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/revealplus é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éfaut false).

  • Bypass d'autorisation : AppServiceProvider::boot() enregistre

    php
    Gate::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 (retour null). Comportement couvert par SuperAdminGateTest.

  • Amorçage : la commande Artisan app:ensure-super-admin cré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'environnement SUPER_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 par EnsureSuperAdminTest.

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 (updateOrCreate sur user_id + accepted_at nul) : ré-inviter rafraîchit le token. L'émission (token CSPRNG + hachage + expiration 7 jours) est centralisée dans Invitation::issueFor(). Helpers isExpired() / 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.password est nullable : un compte invité n'a pas de mot de passe avant l'acceptation (le login exige active, donc un pending ne 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 passe NULL à l'authentification.

Endpoints

  • POST /api/invitationsStoreInvitationController, sous auth: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 optionnels is_super_admin et is_traveller, et assignments — un tableau de paires { organization_id, role } (rolemanager | 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 (sinon 422). En transaction : find-or-create de l'utilisateur (pending, sans mot de passe), pose des flags is_super_admin/is_traveller selon la requête, puis pour chaque attribution : membership invited (idempotent) + rôle scopé à l'organisation (setPermissionsTeamId puis 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 un InvitationResource minimal (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). 404 si inconnu, 410 si expiré ou déjà consommé.
  • POST /api/invitations/{token}/acceptAcceptInvitationController, public. Payload password (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'utilisateur active, bascule ses memberships invitedactive (joined_at), et marque l'invitation accepted_at. 204. Mêmes 404 / 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-chfr, ge-chde, en-gben. 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}/membersStoreOrganizationMemberController

    • AddOrganizationMemberAction. Payload : email, preferred_language et role (manager | employee), tous obligatoires. Trois branches selon l'e-mail :
    1. inconnu — création d'un compte-coquille (pending, noms vides complétés à l'acceptation, is_traveller = false, langue du payload), appartenance invited + rôle scopé, invitation émise (UserInvitationNotification) ;
    2. utilisateur pending — appartenance invited + rôle, invitation rafraîchie (l'ancien lien meurt), e-mail renvoyé ;
    3. utilisateur active — appartenance active immédiate (joined_at = now()) + rôle, e-mail informatif OrganizationMemberAddedNotification dans sa langue (le preferred_language du payload ne sert qu'à la création de compte et n'écrase jamais le réglage d'un utilisateur existant).

    Refus en 422 (jamais 409 — un utilisateur existant est ici le cas normal) : déjà membre actif, appartenance disabled (« ré-activez-la »), compte désactivé. Renvoie 201 + OrganizationResource (trombinoscope à jour).

  • PATCH /organizations/{id}/members/{userId}UpdateOrganizationMemberController + UpdateOrganizationMemberAction. Payload : role et/ou membership_status (active | disabled), au moins l'un des deux. Une appartenance invited ne change pas de statut ici (elle s'active par l'acceptation). Le basculement de statut passe par le pivot Membership (audité automatiquement) ; le changement de rôle par syncRoles scopé (entrée d'audit explicite role_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.

Contributors

No contributors

Changelog

No recent changes