Identity (AUTH) - PRD + FSD V1¶
Module:
identity- story prefixAUTHPlane: Control plane (com.altarys.papillon.pcs.controlplane.identity) Version target: V1 (with explicit V2 schedule notes) Country scope: CI only in V1; design preserves multi-country posture for V2. Authored by: ANALYST session, 2026-04-27. Last reworked: ANALYST session v2, 2026-04-27 - alignment pass withtenant-admin-prd.md. Source legal audit:docs/legal/audit/memo-conformite-CI-v2-audit.md(audité le 30/04/2026 - disposition LEGAL REVIEW REQUIRED). Mémo source :docs/legal/memo-conformite-CI-v2.md(Sections F3 + E pour AUTH côté client ; F6 + B7 côté opérateur).Legal status discipline (memo v2) - toute obligation
INCERTAIN - avocat requisbloque l'implémentation des stories qui en dépendent et impose une note[LEGAL-REVIEW]dans la spec/story. Toute obligationPRÉLIMINAIREdéclenche un avertissement +[LEGAL-REVIEW]. La conformité ne se déduit jamais d'un statutINCERTAIN. Items à surveiller pour AUTH - schéma signer/verifier de magic link (technique : HMAC, expiration 48–72 h, attempt-limit, anti-replay) = non bloqué ; CIMA 001-2024 art. 26 admet le lien à usage unique sur le canal déclaré =VÉRIFIÉ. Opposabilité civile du consentement signé via magic link =INCERTAIN - avocat requis(red flag #5 audit) → toute story qui s'appuie sur le magic link comme PREUVE de consentement (≠ simple authentification) doit porter[LEGAL-REVIEW]. RBAC opérateur + traçabilité =VÉRIFIÉ(CIMA art. 41 + Décision ARTCI 2023-0877).
V0 Envelope Scope (ajout 2026-05-21)¶
Ce bloc est strictement additif : il décrit comment le périmètre AUTH de ce PRD V1 est tranché en sous-versions V0 (v0.0.0 à v0.1.0). Le contenu V1 existant reste la référence cible ; V0 en livre des sous-ensembles successifs. Référence :
docs/prd/prd-v0.md§ 4 (ligneCustomer auth+ ligneOperator auth (Keycloak)), § 5.4 (signed-link spec), § 5.8 (stratégie Keycloak Organizations) et § 8 (mapping stories).
Découpage par sous-version¶
| Sous-version | Périmètre AUTH vs V1 PRD | Story IDs |
|---|---|---|
| v0.0.0 | Keycloak >= 26.6, realm partagé papillon, Keycloak Organizations (une org par tenant), flow ROPC opérateur. Pas d'IdP fédéré, pas de MFA. SignedLinkService côté client : hash SHA-256 stocké (PAS de HMAC), pas d'expiration sur le lien, pas de limite de tentatives appliquée. Auth tables hébergées en platform DB (ADR AUTH-ADR-01 révisée vs V1 PRD). |
AUTH-001 |
| v0.0.1 | Émission + vérification du signed-link token (hash SHA-256) câblées de bout en bout côté Customer Portal. Toujours sans expiration / sans attempt-limit. | AUTH-002 |
| v0.0.2 | Challenge d'identité client = saisie des 4 derniers chiffres du téléphone déclaré (capture lors du premier accès si absent du fichier d'ingestion). Pas de bascule multi-canal, pas d'OTP. | AUTH-003 |
| v0.0.4 | Routage du contexte tenant pour le profil BYO (NSIA) : le resolver de datasource gagne sa branche BYO ; la résolution AUTH côté opérateur reste sur la platform DB, mais les requêtes métiers downstream traversent la BYO DB du tenant. Greenfield uniquement (pas de migration SHARED -> BYO). |
AUTH-004 |
| v0.0.5 | Fédération d'identité par org Keycloak : OIDC + SAML brokering activable par tenant, découverte par domaine email. Carve-out possible vers un realm dédié pour un tenant avec politique de mot de passe / MFA divergente. Cible : grands comptes Enterprise type NSIA. | AUTH-005 |
| v0.1.0 | Mise en application stricte de la limite de tentatives sur le signed-link (recommandation : 5 tentatives ; valeur définitive arbitrée par V0-OQ-02). Couple avec la rate-limit transverse Redis introduite par CP-005 / NOTIF-004. C'est aussi à v0.1.0 que l'expiration 48-72 h des signed-links devient effective (alignement V1 PRD §19). |
AUTH-006 |
Verrouillé pour V0 (rappel des décisions de session)¶
- Keycloak >= 26.6 au lancement v0.0.0 ; suivi de la 26.7 pour FGAP-for-Organizations (cf.
prd-v0.md§ 5.8). - Organisations Keycloak : une org par tenant, créée par le handler
TenantOnboardedcôté AUTH (D-1 du Change Log v2 reste valide ; l'écart V0 est que la création de l'org devient elle aussi un livrable AUTH-001). - SignedLinkService v0.0.0 -> v0.0.5 : hash SHA-256 stocké, sans HMAC, sans expiration, sans attempt-limit. Ce choix est délibéré pour la phase pré-production ; il diverge temporairement du V1 PRD §19 (HMAC + 48-72 h + attempt-limit + anti-replay). Le rattrapage complet est échelonné v0.0.1 (token actif) -> v0.1.0 (expiration + attempt-limit).
- Identités utilisateur AUTH (opérateur) restent en
platform DBmême dans le profil BYO (ADR AUTH-ADR-01). Les tablesusers,users_roleset l'audit AUTH ne se déplacent pas dans la BYO DB du tenant.
Hors V0 (renvoyé à V1 ou plus tard)¶
- MFA WhatsApp OTP côté opérateur : entièrement reporté. Drapeau
feature.whatsapp.operator-mfa.enabled=falsecâblé dès v0.0.0 mais sans implémentation. Décision liée à la posture risque ARTCI (prd-v0.md§ 6). - Anti-replay côté signed-link : conservé en V1 PRD §19 ; non livré en V0.
min_auth_levelNiveau 2 (V2) :TenantSettingsChangedest consommé dès v0.0.0 mais la valeur reste figée à1.- Console de gestion
PLATFORM_ADMIN: provisioning out-of-band (ops runbook) en V0 comme en V1 ; pas de nouvelle UI V0.
Toute story
[LEGAL-REVIEW]héritée du Change Log v2 (notamment celles qui s'appuient sur le magic link comme preuve de consentement, red flag #5) reste bloquée indépendamment du découpage V0. Le découpage ne lève aucun statutINCERTAIN - avocat requis.
Change Log¶
v2 - 2026-04-27 - Alignment pass with TENANT-PRD¶
The tenant-admin PRD was authored after this PRD; this rework folds in the runtime contracts that TENANT-PRD now expects from AUTH. Rework scope was frozen to the diagnostic items below; everything else is unchanged.
| ID | Item | Sections touched |
|---|---|---|
| D-1 | Bootstrap: AUTH consumes TenantOnboarded → (a) creates a Keycloak Organization with id = tenant.id and attributes {country_code, group_id?}, (b) creates the initial CABINET_ADMIN user as a member of that Organization, (c) sends activation invite by email. |
§A.2, §A.3 (AUTH-ADM-04), §B.1 (R-ADM-007), §B.3, §B.7 |
| D-2 | Role taxonomy locked at 5 roles: PLATFORM_ADMIN, CABINET_ADMIN, OPERATOR, SUPERVISOR, READ_ONLY. AUTH is the canonical owner of this enum; TENANT-PRD will be aligned in its next pass. |
§A.2, §A.5, §B.4, §B.5 |
| D-3 | Platform admin declared as a lightweight 5th role (same Keycloak realm, same MFA primitives, no tenant_id scoping by design). No new admin-management UI in V1; OPS provisions out-of-band. |
§A.2, §A.3 (AUTH-PA-01), §B.1 (R-PA-001..R-PA-003), §B.4, §B.5 |
| D-4 | Tenant-lifecycle event handlers: AUTH consumes TenantSuspended, TenantReactivated, TenantSoftDeleted. Hard-cut semantics on suspend; TENANT_SUSPENDED reason added to AUTH_OPERATOR_SESSION_KILLED; soft-delete anonymizes users + hard-deletes the rest. |
§A.3 (AUTH-ADM-05/06/07), §B.1 (R-OP-014..R-OP-016), §B.3, §B.7 |
| D-5 | Tenant-status guard on every AUTH endpoint, both sides. Customer copy matches TENANT-PRD AC-04.1 verbatim; operator copy is new. | §A.3 (AUTH-OP-10), §B.1 (R-OP-017, R-CP-020), Appendix |
| D-6 | AgencyDisabled: AUTH does NOT mutate agency_scope. Downstream modules (OP / ING) gate new actions; historical reads remain accessible. Confirmed alignment with TENANT AC-08.2. |
§B.1 (R-CP-021 / R-OP-018), §B.7 |
| D-7 | BYO-DB cross-DB integrity: users.agency_scope (platform DB) references agency.id (tenant DB). PRD declares the constraint and validation moment (write-time only); mechanism deferred to ARCHITECT. |
§B.6, §C.3 |
| D-8 | min_auth_level acknowledged: V1 hardcoded to 1; AUTH consumes TenantSettingsChanged to invalidate cache when V2 unlocks Level 2. |
§B.5, §B.7 |
| D-9 | Customer landing identity surface now references the formally-captured TENANT fields (legal_name, commercial_name, agrement_number, logo_object_key, primary_color, support_email) in addition to agency phone. |
AUTH-NFR-10, §C.1 |
| D-10 | AUTH_CROSS_TENANT_DENIED ↔ PLATFORM_ADMIN_QUERY cross-reference for the AUDIT module. |
§B.3, §C.3 |
| D-11 | Realm strategy LOCKED: single shared realm papillon on Keycloak >= 26.6, Keycloak Organizations per tenant (id = tenant.id), per-org IdP brokering at v0.0.5, hybrid carve-out path documented (own realm for outlier tenants with fundamentally different password/MFA/token policies). Binding for V1 carry-forward. V0 §5.8 is the canonical source. |
§A.1, §A.2, §A.3 (AUTH-ADM-04, AUTH-ADM-08), §B.7, §C, TVs |
| D-12 | Persona "Agency Admin" (V0 §7, v0.0.2+) maps to CABINET_ADMIN with agency_scope narrowed to a single agency, NOT to a separate Keycloak role. V1 ships 5 roles (D-2). If V2 grows a distinct agency-management UI with separate privileges (e.g. agency-level billing visibility), AGENCY_ADMIN becomes the upgrade path. |
§A.2 (TENANT-PRD persona table), §B.4, §B.5 |
Platform-Admin lifecycle ownership (closes audit N5)¶
| Lifecycle phase | Owning PRD | Notes |
|---|---|---|
| Role enum definition | AUTH (D-2) | Canonical. 5 roles. |
| Initial PLATFORM_ADMIN provisioning | AUTH (R-PA-001) + TENANT (OPS-runbook reference) | Out-of-band in V1; UI in V2. |
| PLATFORM_ADMIN authentication / MFA / step-up | AUTH | Same Keycloak realm papillon, no Organization membership (V0 §5.8). |
| PLATFORM_ADMIN cross-tenant operations UI | TENANT (platform-admin console, TBD UI) | Hosts the actions; auth is AUTH's. |
| PLATFORM_ADMIN audit emission | AUDIT (family PLATFORM_ADMIN_*, per AUDIT-catalog rework) |
Cross-tenant writes audit-logged via PLATFORM_ADMIN_QUERY. |
| First-bootstrap of PLATFORM_ADMIN | OPS runbook (AUTH-OQ-09) | No UI in V1. |
Rework markers (<!-- REWORKED v2 -->) annotate each section that changed. Rule numbering is preserved; new rules use unused IDs in the same series.
Three new TVs were added; one new OQ (AUTH-OQ-09) was added for the first-platform-admin bootstrap (ops-runbook concern).
Part A - Product Requirements¶
A.1 Executive Summary¶
The identity module is the control-plane foundation that gates every authenticated interaction with the platform - on both sides:
Realm strategy (V0 §5.8 lock, binding for V1 carry-forward).
AUTH uses a single Keycloak realm papillon on Keycloak >= 26.6. Each tenant is represented as a Keycloak Organization (id = tenant.id, attributes carry country_code and group_id nullable).
Operator users are members of exactly one Organization; the tenant_id JWT claim is emitted by a protocol mapper from the Organization id (or tenant_id Organization attribute, decision in AUTH arch).
Per-Organization IdP brokering is introduced at v0.0.5 for enterprise tenants. PLATFORM_ADMIN users live in the realm with no Organization membership. A hybrid carve-out path (a dedicated realm for an outlier tenant) is documented but not pre-paid.
See docs/prd/prd-v0.md §5.8 for concrete locks and trade-offs.
- [OPERATOR] Cabinet staff (admins, operators, supervisors, read-only viewers) authenticate via Keycloak (shared
papillonrealm, Organization-membership-scoped) with mandatory SMS OTP MFA and a 15-day per-device remember-me window.
Tenant + agency scoping travels in the access token (tenant_idclaim emitted from Organization membership) so that the application plane (ING, OP, PAY, CP) can enforce isolation in a single pass and so that BYO-DB tenants resolve their datasource with no extra round-trip. - [CUSTOMER] End customers (the assuré or the mandataire for a corporate contract) authenticate via a signed link delivered by WhatsApp + SMS + (when an email is on file) email, challenged by a Level-1 secret - date of birth for individuals, numéro RCCM for legal entities.
No Keycloak account is created per insured. - [BACKEND] Every relevant event (login attempt, MFA stage, session lifecycle, user lifecycle, signed-link issuance / click / failure / lockout / revocation) is emitted to the AUDIT module, with the field set required by V1 PRD §18.
The module solves five concrete problems for V1:
- Provide a credible operator login that meets V1 PRD §17 ("Strong passwords, automatic logout after inactivity") without requiring a TOTP app on every operator's phone - reality of the field is SMS or WhatsApp.
- Replace the CIMA-typical practice of customers being asked to bring their ID to the agency: a customer can be authenticated at home from their phone, with a probative trail.
- Bind tenant + agency scope to every authenticated request so cross-tenant leakage is structurally impossible.
- Cope with imperfect connectivity: the customer link must survive 48–72h of intermittent network and resume sessions with no data loss.
- Be auditable end-to-end: from "campaign validated" through "link issued on channel X" → "click on channel Y" → "DOB attempt 3/5" → "permanent revocation → anomaly".
A.2 Personas¶
| Persona | Side | Auth | Notes |
|---|---|---|---|
| Platform admin (Papillon staff) | [ADMIN] | Keycloak (same realm as operators) + same MFA primitives (SMS OTP + 15-day remember-device + step-up). | Cross-tenant by design. No tenant_id scoping - required by TENANT-PRD §B.5 ("cross-tenant queries on tenant itself"). Holds Keycloak role PLATFORM_ADMIN. Provisioned out-of-band by OPS in V1; first-platform-admin bootstrap is an OPS runbook concern (see AUTH-OQ-09). Consumes TENANT admin endpoints; cannot be impersonated by any operator role. Step-up REQUIRED on every cross-tenant mutating action (D-3 + D-10). |
| Cabinet admin | [OPERATOR] | Keycloak + MFA | Tenant-wide. Invites operators (email or SMS/WhatsApp), assigns roles + agency scope, deactivates users, issues one-time MFA bypass codes. The first CABINET_ADMIN per tenant is created automatically by AUTH on TenantOnboarded - see AUTH-ADM-04. |
| Branch operator | [OPERATOR] | Keycloak + MFA | Scoped to one or more agencies. Daily user - task list, correction workshop, send validation, payment monitoring. |
| Supervisor | [OPERATOR] | Keycloak + MFA | Tenant-wide read + KPI dashboards. Cannot mutate. |
| Read-only viewer | [OPERATOR] | Keycloak + MFA | Tenant-wide read of operational data; no KPI dashboards, no mutations. Typical use: external accountant, internal control. |
| End customer (individual) | [CUSTOMER] | Signed link + DOB | Receives WhatsApp/SMS/email; clicks; enters DOB; views and pays. |
| End customer (mandataire of legal entity) | [CUSTOMER] | Signed link + RCCM | V1: anonymous mandataire - whoever holds the RCCM clicks. V2: pre-registered mandataire (ingestion declares name + phone). |
A.3 User Stories (with Given/When/Then ACs)¶
Story IDs are narrative pointers (
AUTH-OP-XX,AUTH-CP-XX,AUTH-ADM-XX). The PRODUCT_OWNER session may renumber when decomposing into theAUTH-NNNindex file.
Operator login¶
AUTH-OP-01 - Operator first-time password setup [V1] [OPERATOR]
As a newly-invited operator, I want to set my password via the invitation link so that I can sign in.
- Given the cabinet admin has invited me by email (or SMS/WhatsApp if no email), when I click the invitation link before its 72h expiry, then I am asked to choose a password (min 8 characters) and a phone number for MFA.
- Given the link has expired or already been used, when I click it, then I see "Lien d'invitation expiré ou déjà utilisé. Demandez un nouvel accès à votre administrateur." and the cabinet admin is notified in their console.
- Given I set a valid password, when I submit, then I receive a confirmation SMS, my account becomes active, and my creation is audit-logged.
AUTH-OP-02 - Operator routine login with MFA [V1] [OPERATOR]
As an operator, I want to sign in with password + SMS OTP so that I can access the operator console securely.
- Given I enter a valid username + password, when I submit on a device that does NOT carry a valid 15-day remember-me cookie, then an SMS OTP is sent to my registered number, and I am prompted to enter it within 5 minutes.
- Given I enter the correct OTP, when I tick "Se souvenir de cet appareil 15 jours", then a device-bound cookie is issued, I land on the dashboard, and the login is audit-logged with
mfa_factor=SMS_OTP. - Given I sign in from a device that holds a valid 15-day remember-me cookie, when I enter password only, then MFA is skipped, and the login is audit-logged with
mfa_factor=REMEMBER_DEVICE.
AUTH-OP-03 - Operator MFA fallback to WhatsApp [V1] [OPERATOR]
As an operator whose SMS doesn't arrive, I want the OTP to retry via WhatsApp so I can sign in despite carrier issues.
- Given I have triggered an SMS OTP, when I do not validate within 60 s and request a resend, then a 2nd SMS is sent.
- Given the 2nd SMS is also not validated within 60 s, when I request a third resend, then the OTP is sent via WhatsApp to the same number - provided the number was flagged WhatsApp-enabled at user creation; otherwise the UI shows "Demandez à votre administrateur un code de secours."
AUTH-OP-04 - Operator MFA admin reset [V1] [ADMIN]
As a cabinet admin, I want to issue a one-time bypass code when an operator cannot receive any OTP so they can sign in once and re-enroll their phone.
- Given an operator is locked out of MFA, when I generate a bypass code in the operator console with reason code, then a 6-digit code valid for 15 minutes is shown to me on screen and SMS-sent to my OWN admin number; the operator can use it once instead of an OTP.
- Given the code has been used once or 15 minutes have elapsed, when the operator tries to reuse it, then it is rejected; the issuance, the use, and the failure are all audit-logged.
AUTH-OP-05 - Step-up MFA on sensitive actions [V1] [OPERATOR]
As an operator inside a remembered-device session, I want sensitive actions to require a fresh OTP so a stolen session cannot mass-mutate the platform.
- Given I am logged in via remember-device, when I confirm one of {campaign validation, user create/role/deactivate, cabinet parameter change}, then I am prompted for a fresh SMS OTP regardless of the remember-device cookie.
- Given I enter the correct OTP, when I submit, then the action proceeds and is audit-logged with
step_up=true.
AUTH-OP-06 - Operator session timeout [V1] [OPERATOR]
As an operator on a shared machine, I want to be logged out after inactivity so my session cannot be misused.
- Given I have been inactive for 29 minutes, when I am still on a screen, then a 1-minute warning banner appears: "Vous serez déconnecté(e) dans 1 minute. Cliquez ici pour rester connecté(e)."
- Given the warning expires without action, when the 30-minute idle threshold is hit, then I am logged out and any unsaved input is preserved as a draft (per V1 PRD design principles).
- Given I have been logged in for 8 hours continuously, when the absolute lifetime is reached, then I am forcibly re-prompted to log in (password + MFA, even within the 15-day remember-device window).
AUTH-OP-07 - Operator offboarding [V1] [ADMIN]
As a cabinet admin, I want to deactivate an operator who left the cabinet so their access stops immediately.
- Given I open a user detail screen, when I click "Désactiver" and pick a reason (
DÉPART_VOLONTAIRE | LICENCIEMENT | FRAUDE_SUSPECTÉE | AUTRE), then the user's active sessions are killed within 1 minute, all 15-day remember-device cookies are invalidated, and the action is audit-logged with the reason. - Given the deactivated user attempts to sign in, when they enter valid credentials, then they receive "Compte désactivé. Contactez votre administrateur."
Customer signed-link auth¶
AUTH-CP-01 - Customer link issuance [V1] [BACKEND]
As the platform, I issue a unique signed link per customer file per campaign so that the customer can authenticate without an account.
- Given an operator validates a campaign, when the campaign engine schedules dispatch for file F, then AUTH issues a signed link with TTL 72h, attempt counter 0/5, lockout-cycle counter 0/3, and per-channel variants (
?ch=wa,?ch=sms,?ch=em) - and emitsAUTH_LINK_ISSUEDto the audit log. - Given the customer record carries an email, when dispatch occurs, then all three channel variants are sent (WhatsApp, SMS, email); otherwise only WhatsApp + SMS.
AUTH-CP-02 - Customer Level-1 challenge - individual [V1] [CUSTOMER]
As an individual customer, I want to authenticate by entering my date of birth so I can view my contracts.
- Given I click a valid signed link, when the page loads, then I see my broker's logo and agency phone number above the fold, the masked instruction "Pour accéder à vos contrats, entrez votre date de naissance", and a single date input (
JJ/MM/AAAA). - Given I enter my correct DOB, when I submit, then a customer session opens (server-side state, link continues to gate access), I see my eligible contracts, and the click + success are audit-logged.
- Given I enter an incorrect DOB, when I submit, then I see "La date saisie ne correspond pas. Vérifiez votre date de naissance et réessayez. Tentative X/5." and the failure is audit-logged.
AUTH-CP-03 - Customer Level-1 challenge - corporate / RCCM [V1] [CUSTOMER]
As the holder of a SARL contract, I want to authenticate with the company's RCCM number so I can renew without exposing personal data.
- Given the customer record's
auth_factor=RCCM, when the link landing page loads, then the prompt reads "Pour accéder aux contrats de [raison sociale], entrez le numéro RCCM de l'entreprise." with an RCCM-format input. - Given I enter the correct RCCM (case-insensitive, whitespace-tolerant comparison against the stored value), when I submit, then the customer session opens - same downstream behavior as AUTH-CP-02.
- Given the customer record is
souscripteur_type=LEGAL_ENTITYbut no RCCM is on file at ingestion, when the campaign tries to issue a link, then the file is blocked at ingestion as a blocking anomaly (MISSING_RCCM_FOR_LEGAL_ENTITY) and no link is sent.
AUTH-CP-04 - Customer lockout cycle [V1] [CUSTOMER]
As the platform, I limit attempts so a stolen link cannot be brute-forced.
- Given the customer has failed 5 challenge attempts in a row, when the 5th failure is recorded, then the link enters a 30-min lockout, the UI shows "Trop de tentatives. Réessayez dans 30 minutes. En cas de doute, contactez votre conseiller au [téléphone agence].", and the lockout is audit-logged.
- Given 30 minutes elapse, when the customer returns, then the attempt counter resets to 0/5 and the lockout-cycle counter increments by 1.
- Given the lockout-cycle counter reaches 3, when the 15th cumulative failure occurs, then the link is permanently revoked, the file flips to anomaly state
AUTH_PERMANENTLY_LOCKED, the customer sees "Lien désactivé pour des raisons de sécurité. Contactez votre conseiller au [téléphone agence].", and the operator is notified in the file timeline + anomaly view.
AUTH-CP-05 - Customer link expiry [V1] [CUSTOMER]
As a customer who clicks a link more than 72h after issuance, I want a clear next step.
- Given I click a link past its 72h TTL, when the page loads, then I see "Ce lien a expiré. Contactez votre conseiller au [téléphone agence] pour recevoir un nouveau lien.", the click is audit-logged with reason
TTL_EXPIRED, and the file remains visible to the operator (NOT promoted to anomaly - TTL expiry is normal).
AUTH-CP-06 - Customer multi-device click [V1] [CUSTOMER]
As a customer who forwards the link to my accountant, I want both devices to work.
- Given the link is clicked on device A, then later on device B within the 72h TTL and before exhaustion, when device B clicks, then both sessions remain valid concurrently; device B's auth log records
multi_device_click=truewith both IPs and user-agents.
AUTH-CP-07 - Customer link auto-revocation triggers [V1] [BACKEND]
As the platform, I revoke a signed link automatically when the renewal cycle no longer needs it.
- Given a link covers contracts {C1, C2, C3} and all three are paid, when the third payment is confirmed, then the link is revoked and the customer's next click shows "Vous avez déjà payé tous les contrats de cette échéance. Merci."
- Given a link covers {C1, C2, C3} and only C1 + C2 are paid, when the customer returns, then the link STILL works for C3 until TTL expiry or 3 lockout cycles - partial payment does NOT revoke.
- Given the customer is mid-session in a single payment for the link, when TTL expires, then the in-flight payment is allowed to complete (idempotency-keyed) but no further interaction is accepted on the link.
AUTH-CP-08 - Customer link re-issuance by operator [V1] [OPERATOR]
As an operator whose customer says "I never received the message", I want to re-issue a fresh link.
- Given I am on a file detail with an active or expired link, when I click "Renvoyer le lien" and confirm, then a brand-new signed link is issued (TTL 72h, counter 0/5, cycle 0/3), the previous link is auto-revoked (any concurrent customer session is dropped), and the action is audit-logged with my user ID + reason.
- Given I want to re-issue, when I submit, then there is no V1 cap on re-issuance count (V2 may add a cap of 3/file/campaign).
Tenant scoping¶
AUTH-OP-08 - Tenant + agency scope on every operator request [V1] [BACKEND]
As the platform, I bind every authenticated operator request to a tenant and agency scope so cross-tenant data cannot leak.
- Given an operator's access token is issued, when the application plane receives a request, then the token's scoping claims (tenant, agency scope, country, roles) are read by the AUTH filter, hydrated into a request-scoped
TenantContext, and consumed by every downstream repository - exact claim names + serialization are an ARCHITECT decision. - Given an OPERATOR has agency scope
{A1}and queries data for agencyA2, when the query reaches the application plane, then the request is rejected403 FORBIDDENand the attempt is audit-logged.
AUTH-OP-09 - Role hierarchy [V1] [OPERATOR]
As the platform, I enforce role privileges so each persona sees only what their role permits.
- Given the four V1 roles (
CABINET_ADMIN,OPERATOR,SUPERVISOR,READ_ONLY), when a request reaches a guarded endpoint, then access is granted per the decision table in §B.4. - Given a CABINET_ADMIN demotes a user to OPERATOR mid-session, when the user's current access token expires (≤ 15 min), then the demotion takes effect on the next refresh; immediate kick is V2.
Tenant-lifecycle handlers (consumed events from TENANT module)¶
AUTH-ADM-04 - Bootstrap initial CABINET_ADMIN on tenant activation [V1] [BACKEND]
As the platform, on tenant activation I want to provision the first CABINET_ADMIN automatically so that the tenant can be operated without a manual identity-store step.
- Given TENANT publishes
TenantOnboarded{tenantId, countryCode, groupId?, primaryContact, defaultAgencyId, ...}, when AUTH receives it, then AUTH (a) creates a Keycloak Organization in thepapillonrealm withid = tenantIdand attributes{country_code: countryCode, group_id: groupId}(D-1, D-11), (b) creates a Keycloak user as a member of that Organization with roleCABINET_ADMINand the tenant'sprimary_contact_emailas login, (c) creates the projection row inuserswithtenant_id,role=CABINET_ADMIN,agency_scope=NULL(tenant-wide per R-OP-022),mfa_phone=primary_contact_phone,state=PENDING_ACTIVATION, (d) issues a 7-day activation invitation via NOTIF on the email channel - payload is identical to AUTH-ADM-01. - Given the event is re-delivered, when AUTH processes it, then the operation is idempotent: a second processing detects the existing user (matched on
tenant_id+ login) and emitsinitial_cabinet_admin_provisionedwithidempotent_skip=truerather than creating a duplicate. - Given activation invitation dispatch fails (NOTIF down, email bounces), when the failure is detected, then the user remains in
PENDING_ACTIVATION, the failure is audit-logged, and the platform admin can re-trigger the invitation from the TENANT detail screen (TENANT calls back into AUTH) - V1 has no automatic retry beyond NOTIF's own delivery retries.
AUTH-ADM-05 - Tenant suspended cascade [V1] [BACKEND]
As the platform, when a tenant is suspended I want every authenticated surface for that tenant to stop within 30 seconds so that operators and customers cannot continue to operate on a frozen account.
- Given TENANT publishes
TenantSuspended{tenantId, reasonCode, suspendedAt}, when AUTH receives it, then within 30 s - (a) every Keycloak access token for users of that tenant is revoked via Keycloak admin API, (b) every active operator session row is force-killed withreason=TENANT_SUSPENDED, (c) everyremember_device_cookiesrow for users of that tenant is invalidated, (d) everysigned_linksrow for that tenant is set to internal flagtenant_frozen=true, (e) every active customer session bound to such a link is dropped. - Given an in-flight customer payment was running when the suspend fires, when the customer attempts to continue, then the signed-link verifier returns HTTP 503 with
auth.tenant.suspended.customercopy. The payment's webhook reconciliation continues server-side (idempotent); no double-charge can occur. - Given any operator attempts to log in during the suspend window, when they submit credentials, then AUTH returns
auth.tenant.suspended.operator(« Cabinet suspendu. Contactez le support Papillon. ») without revealing the suspension reason text. The login attempt is audit-logged withfailure_reason=TENANT_SUSPENDED.
AUTH-ADM-06 - Tenant reactivated cascade [V1] [BACKEND]
As the platform, on tenant reactivation I want operators and customers to be able to log back in within 30 seconds, while keeping a clean audit boundary.
- Given TENANT publishes
TenantReactivated{tenantId, reactivatedAt}, when AUTH receives it, then within 30 s - (a) thetenant_frozenflag on allsigned_linksfor the tenant is cleared, (b) Keycloak users of the tenant are re-enabled, (c) operators must log in again (no session is restored - sessions and remember-device cookies invalidated during AUTH-ADM-05 stay invalidated), (d) customers click their existing signed link as usual; counters and cycle state are preserved through the freeze (R-CP-002). - Given a signed link was within its 72h TTL when the suspend fired, when the tenant is reactivated 6h later, then the link's
expires_atis NOT extended - the original 72h TTL stands. Operator can re-issue (AUTH-CP-08) if needed.
AUTH-ADM-07 - Tenant soft-delete cascade [V1] [BACKEND]
As the platform, on tenant soft-delete I want AUTH's identity tables to align with TENANT's PII purge intent, while preserving audit-log referential integrity.
- Given TENANT publishes
TenantSoftDeleted{tenantId, deletedAt}, when AUTH receives it, then AUTH (a) anonymizesusersrows for the tenant - NULLsdisplay_name,email,mfa_phone, setsmfa_whatsapp_enabled=false, setsstate=DEACTIVATED, setsdeactivation_reason=TENANT_SOFT_DELETED, keepsuser_id+tenant_idso audit-log FKs remain intact; (b) hard-deletes all rows fromuser_invitations,remember_device_cookies,mfa_bypass_codes,signed_links,customer_auth_attemptsfor the tenant; (c) disables corresponding Keycloak users (NOT delete - Keycloak hard-delete would orphan event_id references in audit); (d) emitstenant_auth_purgedwith counts. - Given anonymization fails partway, when the failure is detected, then the operation is retryable (idempotent) - anonymized rows pass-through (already NULL); the operator-side TENANT detail still shows the partial state until the next retry.
AUTH-OP-10 - Tenant-status guard on every authenticated surface [V1] [BACKEND]
As the platform, I want every AUTH endpoint to reject calls for tenants that are not ACTIVE so that suspended or deleted tenants cannot transact.
- Given any AUTH endpoint is invoked (operator login, token introspection, signed-link verify, signed-link issuance), when the request resolves a tenant, then AUTH reads the tenant's status from a low-latency cache (Redis-fronted, invalidated on
TenantSuspended/TenantReactivated/TenantSoftDeleted) and rejects unlessstatus=ACTIVE. - Given the tenant is
SUSPENDED, when a customer click hits the signed-link verifier, then the response is HTTP 503 with copyauth.tenant.suspended.customer(« Service temporairement indisponible. Contactez votre cabinet. » - verbatim TENANT AC-04.1). The reason text is NEVER exposed. - Given the tenant is
DELETEDorPENDING, when any auth request arrives, then the response is HTTP 410 (DELETED) or HTTP 503 (PENDING) - operator side getsauth.tenant.unavailable.operator; customer side gets the generic 503 copy regardless of underlying status (no information leak).
AUTH-ADM-08 - Per-Organization IdP brokering [V0.0.5] [BACKEND]
As a platform integrator, on enterprise-tenant onboarding (v0.0.5+), I want to link the tenant's Keycloak Organization to its own external IdP (OIDC or SAML) so that the tenant's employees authenticate against their corporate identity provider with no separate password in papillon.
- Given a tenant Organization exists in the
papillonrealm (created by AUTH-ADM-04), when OPS configures an IdP brokering link at the Organization level via the Keycloak Admin REST API (POST /admin/realms/papillon/organizations/{org}/identity-providers), then users typing an email matching the tenant's verified domain(s) are redirected to the tenant's IdP (domain-based discovery). - Given a federated user lands on
/loginand selects their tenant's IdP, when the IdP returns a successful assertion, then Keycloak IdP mappers (Hardcoded Group, Advanced Claim to Group) auto-place the federated user into per-Organization groups corresponding to their role; thetenant_idJWT claim is emitted from the Organization id. - Given the IdP brokering link is mis-configured (invalid SAML metadata, OIDC discovery endpoint unreachable), when OPS commits the change, then AUTH surfaces the Keycloak validation error in the platform-admin console and falls back to local ROPC; OPS retries.
- v0.0.0 to v0.0.4: ROPC against shared realm only. No IdP brokering. v0.0.5 lands this story.
Platform-admin auth (lightweight)¶
AUTH-PA-01 - Platform admin login [V1] [ADMIN]
As a Papillon staff member, I want to log into the platform with the same MFA primitives as cabinet operators so that we have one consistent identity surface in V1.
- Given I have been provisioned by OPS (out-of-band) with role
PLATFORM_ADMINin the Keycloak realm, when I sign in, then the flow is identical to AUTH-OP-02 (password + SMS OTP + 15-day remember-device) and lands me on the platform-admin back-office. - Given my access token is issued, when any application-plane endpoint reads it, then the token carries
role=PLATFORM_ADMINand notenant_idclaim - by design, since platform admins are cross-tenant. Endpoints scoped to a single tenant (/api/tenant/me/...) rejectPLATFORM_ADMINwith 403; cross-tenant control endpoints (/api/control/tenants/...) accept it. - Given I attempt one of the cross-tenant mutating actions {
tenant create,tenant suspend,tenant reactivate,tenant identity edit,compliance doc upload}, when I submit, then AUTH requires a fresh SMS OTP step-up (R-OP-010 list extended for platform-admin actions) regardless of remember-device state.
Operator user management¶
As a cabinet admin, I want to invite an operator with a single screen so onboarding is fast.
- Given I open "Inviter un utilisateur", when I fill name + role + agency scope (if OPERATOR) + invitation channel (email | SMS | WhatsApp) + corresponding contact, then an invitation is dispatched on the chosen channel; the user enters a
PENDING_ACTIVATIONstate. - Given the invited user has not activated within 7 days, when the deadline passes, then the invitation is auto-expired, the cabinet admin is notified, and a new invitation must be issued.
- Given I select
SMSorWhatsAppas channel, when the system dispatches the invitation, then the link payload is identical to the email path; only the delivery channel differs.
AUTH-ADM-02 - Cabinet admin role / scope change [V1] [ADMIN]
As a cabinet admin, I want to change a user's role and agency scope.
- Given I edit a user, when I change the role from OPERATOR to SUPERVISOR (and clear agency scope), then the change is saved, the user's existing access tokens stay valid until natural expiry (≤ 15 min), and the change is audit-logged.
AUTH-ADM-03 - Operator self password reset [V1] [OPERATOR]
As an operator who forgot my password, I want to reset it without bothering my admin.
- Given I click "Mot de passe oublié", when I enter my username, then a one-time reset link valid 1h is sent to my registered email (if any) or to my MFA phone via SMS.
- Given I click the reset link, when I set a new password and confirm a fresh SMS OTP, then my password is changed and the event is audit-logged.
A.4 Non-Functional Requirements¶
| ID | Requirement | Side |
|---|---|---|
| AUTH-NFR-01 | Customer signed-link landing page initial payload < 200 KB gzipped, usable on Slow 3G (V1 PRD §30). | [CUSTOMER] |
| AUTH-NFR-02 | Customer pages mobile-first 360 px portrait, touch targets ≥ 44 px. | [CUSTOMER] |
| AUTH-NFR-03 | Signed-link TTL 72h ; attempt limit 5 / cycle ; lockout 30 min ; max 3 cycles. | [BOTH] |
| AUTH-NFR-04 | Operator session: idle 30 min (with 1-min warning), absolute lifetime 8h. | [OPERATOR] |
| AUTH-NFR-05 | Operator MFA 15-day remember-device, with step-up on sensitive actions. | [OPERATOR] |
| AUTH-NFR-06 | Operator offboarding: active sessions killed within 1 min of deactivation. | [OPERATOR] |
| AUTH-NFR-07 | All authentication strings in French (fr-FR), JJ/MM/AAAA date format, phone display +225 XX XX XX XX. |
[BOTH] |
| AUTH-NFR-08 | Every event listed in §B.3 emits to AUDIT with the field set required by V1 PRD §18. | [BACKEND] |
| AUTH-NFR-09 | Customer DOB / RCCM masked by default in operator console; reveal-on-click is audit-logged. | [OPERATOR] |
| AUTH-NFR-10 | Customer landing page always shows the broker identity surface in the header - every screen, every state. The surface includes: tenant logo_object_key, commercial_name, agency phone (agency.phone), tenant support_email, and (in the footer / "Mentions légales" link) tenant legal_name + agrement_number. Source fields are owned by TENANT-PRD §B.4. |
[CUSTOMER] |
| AUTH-NFR-11 | Signed-link verification is local (no external auth provider hit) and must complete server-side in < 50 ms p95 to keep the customer landing under the 200 KB / Slow-3G budget meaningful. | [BACKEND] |
| AUTH-NFR-12 | Channel-of-click is captured per click and stored on the auth event (no PII derived from it). | [BACKEND] |
| AUTH-NFR-13 | Tenant-status guard latency: tenant-status cache invalidation propagates within 30 s of receiving a Tenant{Suspended,Reactivated,SoftDeleted}Event. AUTH endpoints read tenant status from cache (Redis-backed) on every authenticated request. |
[BACKEND] |
| AUTH-NFR-14 | Tenant-lifecycle handler completion targets: TenantSuspended cascade complete within 30 s p95 (matches TENANT AC-04.1); TenantReactivated cascade within 30 s p95; TenantSoftDeleted cascade within 5 min p95 (allows for bulk anonymization on large tenants). |
[BACKEND] |
| AUTH-NFR-15 | min_auth_level is consumed from tenant_settings via the TENANT module. V1 hardcodes Level 1 regardless of the field; AUTH consumes TenantSettingsChanged only to invalidate cache so that V2 unlocking Level 2 requires no AUTH code path change beyond the level-2 challenge implementation. |
[BACKEND] |
A.5 V1 vs V2 vs V3¶
| Capability | V1 | V2 | V3 |
|---|---|---|---|
| Operator login (Keycloak) | ✅ | ✅ enhanced (TOTP option, hardware keys) | ✅ enterprise SSO (SAML/OIDC federation) |
| Operator MFA | ✅ SMS OTP + 15d remember-device + step-up | TOTP option ; risk-based step-up rules engine | Adaptive auth, hardware keys |
| Roles | ✅ 5 (PLATFORM_ADMIN + CABINET_ADMIN / OPERATOR / SUPERVISOR / READ_ONLY) |
+ delegations + agency-scoped admin | + advanced RBAC, fine-grained permissions |
| Platform-admin separate Keycloak realm | ❌ same realm in V1 (D-3 lightweight) | optional silo | ✅ |
| Tenant-lifecycle event handlers (suspend/reactivate/soft-delete) | ✅ | ✅ enriched (per-event SLAs, partial-tenant freeze) | ✅ |
Initial CABINET_ADMIN bootstrap from TenantOnboarded |
✅ email-only | + multi-channel (email + SMS belt-and-suspenders) | ✅ |
min_auth_level setting wired through AUTH |
✅ V1 locked to Level 1 | ✅ tenant-configurable Level 2 | ✅ tenant-configurable up to Level 3 |
| Customer auth Level 1 (link + DOB/RCCM) | ✅ | ✅ | ✅ |
| Customer auth Level 2 (link + DOB/RCCM + SMS OTP) | ❌ | ✅ | ✅ |
| Customer auth Level 3 (Q/A or recovery) | ❌ | ❌ | ✅ |
| Mandataire identity capture | ❌ anonymous | ✅ pre-registered (ingestion-declared) | ✅ + delegation chain |
| Manual link revocation by operator | ❌ auto only | ✅ (with reason code) | ✅ |
| Re-issuance cap | none | 3/file/campaign | configurable |
| Dedicated "auth events" screen | ❌ (only file timeline + anomaly view) | ✅ | ✅ + analytics |
| Multi-realm Keycloak (silo per enterprise tenant) | ❌ single realm | optional silo | platform-managed federation |
| BYO-DB tenant resolution from JWT | ✅ | ✅ + schema-per-tenant | ✅ |
| Cross-border auth (multi-country tenant pool) | ❌ CI only | ✅ per-country tenant | ✅ federated |
A.6 Open Questions¶
| ID | Topic | Resolution required by |
|---|---|---|
| AUTH-OQ-01 | Acceptability of signed-link + DOB/RCCM under CIMA Reg. 01-24 for renewal consent and payment authorization - anchor article required. (Legal-Compliance audit gap #1, CRITICAL.) | Before V1 production launch - qualified lawyer. |
| AUTH-OQ-02 | Audit-log retention period for AUTH events (login, MFA, signed-link). (Legal-Compliance audit gap #2 + #11, CRITICAL.) | ARCHITECT session, before AUDIT module finalization. |
| AUTH-OQ-03 | CIMA cybersecurity regulation (010-24 or current equivalent) - concrete obligations on a sub-processor for auth: encryption, BCP, incident notification. (Legal-Compliance audit gap #4, CRITICAL.) | Before V1 production launch - qualified lawyer + LEGAL_AUDITOR re-pass. |
| AUTH-OQ-04 | Cross-border data flow for SMS aggregator + WhatsApp Cloud API (operator phone goes to Meta-hosted infra outside CI/UEMOA). (Legal-Compliance audit gap #6, CRITICAL.) | Before NOTIF integration - legal review. |
| AUTH-OQ-05 | Mandatory information surface on the customer auth landing page under Reg. 01-24 (article-anchored list - CGU, broker identity, privacy notice, contestation procedure). (Legal-Compliance audit gap #9, HIGH.) | DESIGNER session, before customer-portal wireframes. |
| AUTH-OQ-06 | Lawful basis for processing auth-related data (operator phone for MFA, customer DOB/RCCM for the challenge). (Legal-Compliance audit gap #8, HIGH.) | Before V1 production launch - DPIA / lawyer. |
| AUTH-OQ-07 | DPO / "correspondant à la protection des données" trigger threshold under law n°2013-450 - does the platform publisher need to appoint one ? | TENANT module work. |
| AUTH-OQ-08 | WhatsApp number opt-in for operator MFA fallback - does collecting "WhatsApp-enabled" status at user creation count as opt-in for service messages ? | DPIA. |
| AUTH-OQ-09 | First-platform-admin bootstrap procedure - creating the first PLATFORM_ADMIN user before any TENANT exists is an OPS runbook concern, NOT a product feature in V1. The runbook must (a) create the Keycloak realm papillon if it does not exist (idempotent), (b) create the first user with PLATFORM_ADMIN role and a strong password, (c) enroll an MFA phone, (d) document break-glass recovery if the only admin loses MFA access. (Tracked here so it is not forgotten; resolution lives in the OPS handbook, not this PRD.) |
OPS, before V1 production launch. |
| AUTH-OQ-10 | Hybrid carve-out trigger (D-11). What concrete tenant attribute marks a tenant as needing dedicated-realm placement? Recommendation: tenant_settings.realm_strategy ∈ {SHARED (default), DEDICATED}. ARCHITECT decides whether the resolver lives in AUTH or TENANT. |
AUTH architecture session, before any DEDICATED tenant is needed in production. |
A.7 Temporarily Validated¶
These are decisions implemented as-is in V1 pending the resolutions above:
- AUTH-TV-01 - Signed-link + DOB/RCCM is accepted as the customer auth scheme for V1, on the working assumption that it qualifies as proportionate authentication under Reg. 01-24 for a renewal-consent flow that does not modify the contract. To be validated by AUTH-OQ-01.
- AUTH-TV-02 - AUTH audit events are retained "indefinitely" in V1 (no automatic purge job), pending AUTH-OQ-02. ARCHITECT must implement audit storage in a way that supports a future retention purge.
- AUTH-TV-03 - Operator phone numbers (used for MFA SMS) are stored within the operator user record on the platform's control-plane DB. No separate retention rule applies - they live as long as the user account does. To be validated by AUTH-OQ-06.
- AUTH-TV-04 - Customer DOB / RCCM: the challenge value is stored in the customer record (ingested from the broker's file). Comparison happens server-side. The value is masked in the operator console (AUTH-NFR-09). Retention follows the broader customer-record retention (TBD in TENANT / AUDIT modules).
- AUTH-TV-05 - Platform admins share the operator Keycloak realm and operator MFA primitives in V1 (D-3 lightweight). Realm-per-platform-admin or hardware MFA for OPS staff is a V2 hardening candidate; not implemented now.
- AUTH-TV-06 - Hard-cut semantics on
TenantSuspended(D-4): in-flight customer payments are not given a grace window in V1. Reconciliation via webhook is idempotent (V1 PRD §11.6 + §30) so no double-charge can occur, but the customer cannot interactively complete payment until the tenant is reactivated. Operationally: platform admin should not suspend during business hours unless necessary. - AUTH-TV-07 - Cross-DB referential integrity for
users.agency_scope(platform DB) →agency.id(tenant BYO-DB) is validated at write time only (when CABINET_ADMIN assigns scope). No periodic re-validation in V1.AgencyDisableddoes not mutate scope (D-6); downstream modules gate. ARCHITECT may strengthen this. - AUTH-TV-08 - Keycloak Organizations scaling at hundreds of orgs per realm: feature is GA since Keycloak 26.0, matured through 26.6 (Apr 2026). No public load-test data at hundreds-of-orgs scale. Validated during v0.0.5 hardening (V0 PRD TV row 4). Fallback: hybrid carve-out path (D-11) lets us move outlier tenants to dedicated realms if scaling pain emerges.
- AUTH-TV-09 - FGAP-for-Organizations (Keycloak 26.7 preview May 2026, GA TBD) is needed for per-Organization role assignment without realm-role workaround. Interim until GA: thin custom admin UI in the OP module handles cabinet-admin delegation. Expected to be replaced by the standard Keycloak Admin Console once 26.7 lands.
Part B - Functional Specifications¶
B.1 Functional Rules (numbered)¶
Operator authentication¶
- R-OP-001 - IF an operator submits a login form THEN password is validated against the Keycloak credential store; ELSE invalid → return generic error "Identifiant ou mot de passe incorrect." (no account-existence leak), and emit
login_failureaudit event. - R-OP-002 - IF password validation succeeds AND the request carries a valid 15-day remember-device cookie matching the user THEN MFA is skipped, session is opened, audit event
login_successwithmfa_factor=REMEMBER_DEVICE. ELSE MFA challenge is required. - R-OP-003 - IF MFA is required THEN the platform sends an SMS OTP to the operator's registered phone; OTP is 6 digits, valid 5 minutes, single-use.
- R-OP-004 - IF the operator does not validate the SMS OTP within 60 s and requests a resend THEN a 2nd SMS OTP is dispatched (same code lifecycle). IF the 2nd SMS is also not validated within 60 s and a 3rd resend is requested THEN - IF the operator's number is flagged WhatsApp-enabled - the OTP is sent via WhatsApp; ELSE a French message tells them to ask their admin for a bypass code.
- R-OP-005 - IF the operator enters the OTP correctly THEN the session is opened. IF they tick "Se souvenir de cet appareil 15 jours" THEN a remember-device cookie is set (HttpOnly, Secure, SameSite=Strict, 15-day expiry, bound to user-id + a server-side fingerprint of UA + IP block - exact fingerprint scheme is an ARCHITECT decision).
- R-OP-006 - IF the operator fails OTP entry 3 times in a single login session THEN the login attempt is aborted, MFA is locked for 15 minutes for that user, and the failure is audit-logged.
- R-OP-007 - IF a CABINET_ADMIN issues a one-time bypass code THEN it is 6 digits, valid 15 min, single-use, and the issuance is audit-logged with the admin's user ID. The code is shown on screen to the admin AND sent to the admin's own MFA number - never to the locked-out operator directly.
- R-OP-008 - IF the user account is in
PENDING_ACTIVATION(invited but never set password) THEN any login attempt is rejected with "Compte non activé. Cliquez sur le lien d'invitation reçu." - no MFA challenge. - R-OP-009 - IF the user account is
DEACTIVATEDTHEN any login attempt is rejected with "Compte désactivé. Contactez votre administrateur." - R-OP-010 - Sensitive actions ALWAYS trigger a fresh SMS OTP step-up regardless of remember-device cookie state. Step-up OTP failure 3× aborts the action. The full step-up list is:
- Operator-side:
OP_CAMPAIGN_VALIDATED,OP_USER_CREATED,OP_USER_ROLE_CHANGED,OP_USER_DEACTIVATED,OP_PARAMETER_CHANGED. - Platform-admin-side:
TENANT_CREATE,TENANT_PROVISION_RETRY,TENANT_SUSPEND,TENANT_REACTIVATE,TENANT_IDENTITY_EDIT,COMPLIANCE_DOC_UPLOAD,BYO_DB_CREDENTIAL_EDIT. - R-OP-011 - Idle session timeout: 30 minutes (server-side authoritative). Absolute session lifetime: 8 hours. UI shows a 1-minute countdown warning before idle kick.
- R-OP-012 - On operator deactivation by CABINET_ADMIN: (a) all access tokens for the user are immediately revoked (Keycloak admin API); (b) all 15-day remember-device cookies for that user are invalidated server-side; (c) any open WebSocket / SSE channel is closed; (d) propagation must complete within 60 seconds.
- R-OP-013 - Password policy V1: min 8 chars, no complexity rule, no rotation, locks the account after 5 consecutive failures (admin unlock required). V2 schedule: 12+ chars + HaveIBeenPwned breach check.
Tenant-lifecycle handlers + tenant-status guard (D-4 + D-5)¶
- R-OP-014 - On
TenantSuspended(tenantId): within 30 s, AUTH (a) calls Keycloak admin API to revoke all access tokens for users wheretenant_id=tenantId, (b) updatesusers.stateof all rows for the tenant to a transient markerTENANT_FROZEN(set as a flag, not a state replacement - originalstatepreserved for restoration), (c) marks everyremember_device_cookies.revoked_at=now()for the tenant, (d) sets atenant_frozen=trueflag on every non-terminalsigned_linksrow for the tenant, (e) drops every active customer session bound to such a link, (f) writes audit eventtenant_suspended_handledwith the action counts. The cache backing R-OP-017 is invalidated as the FIRST step of this rule. - R-OP-015 - On
TenantReactivated(tenantId): within 30 s, AUTH (a) clearstenant_frozen=trueonsigned_links, (b) clears theTENANT_FROZENflag onusers(state restored to its pre-freeze value - typicallyACTIVE), (c) does NOT restoreremember_device_cookies(intentionally invalidated; users must log in again), (d) re-enables Keycloak users, (e) invalidates the tenant-status cache, (f) writes audit eventtenant_reactivated_handled. - R-OP-016 - On
TenantSoftDeleted(tenantId): AUTH performs the full purge described in AUTH-ADM-07. Specifically:usersrows are anonymized in place (display_name=NULL,email=NULL,mfa_phone=NULL,mfa_whatsapp_enabled=false,state=DEACTIVATED,deactivation_reason=TENANT_SOFT_DELETED,user_idandtenant_idPRESERVED for audit FK integrity);user_invitations,remember_device_cookies,mfa_bypass_codes,signed_links,customer_auth_attemptsrows for the tenant are HARD-deleted; corresponding Keycloak users are DISABLED (NOT deleted - preserves Keycloak event_id references in audit log). Audit eventtenant_auth_purgedcarries per-table counts. Operation is idempotent (re-running on an already-purged tenant is a no-op). - R-OP-017 - Tenant-status guard: every AUTH endpoint (operator login, token introspection, signed-link verify, signed-link issuance, user create/edit) MUST resolve the tenant and check
tenant.status. The check reads from a Redis-backed cache invalidated on R-OP-014/R-OP-015/R-OP-016. IFstatus != ACTIVETHEN - IFstatus=SUSPENDEDand side=customer THEN return HTTP 503 +auth.tenant.suspended.customer. IFstatus=SUSPENDEDand side=operator THEN returnauth.tenant.suspended.operatorwithfailure_reason=TENANT_SUSPENDED. IFstatus=DELETEDTHEN return HTTP 410 (operator) or HTTP 503 (customer - no leak). IFstatus=PENDINGTHEN return HTTP 503 (operator only - customer never has a link to a PENDING tenant by R-CP-018+R-OP-014). - R-OP-018 - On
AgencyDisabled(tenantId, agencyId): AUTH does NOT mutate anyagency_scopearray. The disabledagency_idREMAINS in scope arrays of OPERATORs that had it. Downstream modules (OP / ING / PAY) are responsible for refusing new mutating actions targeting the disabled agency, per TENANT AC-08.2. AUTH MAY emitAUTH_AGENCY_DISABLED_ACKfor audit traceability. On re-enable (AgencyReactivated), the scope is already correct - no mutation needed. - R-OP-019 - On
TenantSettingsChanged(tenantId, changedKeys): IFchangedKeysincludesmin_auth_levelTHEN AUTH invalidates its tenant-config cache for that tenant. V1 hardcodes Level 1; the invalidation is in place so V2 unlocking Level 2 is a config-only change at AUTH side.
Tenant + agency scoping¶
- R-OP-020 - Every authenticated operator request MUST resolve a
TenantContextcarrying tenant ID, agency-scope set, country code, and role set, before any business logic runs. ARCHITECT decides whether all of these come from the access token or some are server-resolved; the AUTH module exposes the contract. - R-OP-021 - IF an OPERATOR's agency-scope set is
{A1, A2}AND a request targets data of agencyA3THEN the request is rejected403 FORBIDDEN; the attempt is audit-logged. - R-OP-022 - Tenant-wide roles
{CABINET_ADMIN, SUPERVISOR, READ_ONLY}carry an implicit "all agencies of this tenant" scope; OPERATOR carries an explicit subset. - R-OP-023 - BYO-DB profile: tenant_id from the access token is sufficient input for the tenant-aware datasource resolver to pick the right Postgres connection. The AUTH module SHALL NOT touch a tenant DB to validate a token.
Customer signed-link auth¶
- R-CP-001 - A signed link is issued per (campaign_id, file_id) pair. The link is unique, unforgeable, and carries no PII in clear in its URL.
- R-CP-002 - Each link has TTL 72h, attempt counter starting at 0/5, lockout-cycle counter starting at 0/3. State (counters, last failure timestamp, lockout-until timestamp, revocation flag) is stored server-side and consulted on every click.
- R-CP-003 - Per-channel variants: when the link is dispatched to multiple channels, each channel receives a variant URL with a
?ch=query parameter (wa | sms | em). The HMAC core is identical; the channel marker is logged but does not affect signature validation. - R-CP-004 - On link click: AUTH (a) validates signature, (b) checks TTL, (c) checks revocation flag, (d) reads attempt counter, (e) emits
AUTH_LINK_CLICKEDwith the channel marker + IP + user-agent. - R-CP-005 - On click of an EXPIRED link: customer sees "Ce lien a expiré. Contactez votre conseiller au [téléphone agence] pour recevoir un nouveau lien." The file does NOT promote to anomaly (TTL expiry without use is normal). The expired-click is audit-logged with reason
TTL_EXPIRED. - R-CP-006 - On click of a REVOKED link (auto- or operator-re-issued): customer sees "Lien désactivé. Contactez votre conseiller au [téléphone agence]." The revoked-click is audit-logged with reason
REVOKED. - R-CP-007 - Challenge submission: the customer's input is normalized (DOB → ISO date ; RCCM → uppercased, stripped of whitespace) and compared to the stored challenge value. Comparison is constant-time.
- R-CP-008 - On challenge success: a server-side customer session is opened, bound to the link token. The link continues to gate access (it is the bearer credential). Audit event
customer_auth_success. - R-CP-009 - On challenge failure: counter increments by 1; UI shows "La date saisie ne correspond pas. Vérifiez votre date de naissance et réessayez. Tentative X/5." (or RCCM equivalent). Audit event
customer_auth_failure. - R-CP-010 - On 5th consecutive failure: link enters 30-min lockout (lockout-until = now + 30 min). Counter stays at 5/5; cycle counter increments. Customer sees "Trop de tentatives. Réessayez dans 30 minutes."
- R-CP-011 - On click during lockout window: customer sees "Réessayez dans X minutes." - no attempt is consumed.
- R-CP-012 - On lockout-window expiry (next click after lockout-until): IF cycle counter < 3 THEN counter resets to 0/5, customer is presented with the challenge again. ELSE → R-CP-013.
- R-CP-013 - On reaching cycle counter = 3 (i.e. 15 cumulative failures): link is permanently revoked; the file is promoted to anomaly state
AUTH_PERMANENTLY_LOCKED; operator is notified via the file timeline + the anomaly view; customer sees "Lien désactivé pour des raisons de sécurité. Contactez votre conseiller."; audit eventAUTH_LINK_PERMANENTLY_REVOKED. - R-CP-014 - Auto-revocation triggers (in addition to R-CP-013): (a) all eligible contracts on the link are paid → revoke; (b) operator re-issues link via AUTH-CP-08 → previous link revoke; (c) TTL expiry → no explicit revoke event, but link is unusable.
- R-CP-015 - Partial payment does NOT revoke. The link remains usable for unpaid contracts on the same link until R-CP-013 / R-CP-014 (a) fires.
- R-CP-016 - Multi-device clicks within the 72h TTL are allowed. All clicks are logged with their respective IPs and user-agents. No session is killed on a new-device click in V1.
- R-CP-017 - Re-issuance: when an operator re-issues a link for a file, the previous link is auto-revoked AND its server-side state (counters, sessions) is marked terminal. The new link starts at counter 0/5, cycle 0/3, fresh 72h TTL. Re-issuance is uncapped in V1.
- R-CP-018 - IF a link covers customer C with
auth_factor=DOBAND C's record has no DOB on file at link-issuance time THEN the link MUST NOT be issued; the file is blocked at ingestion as a blocking anomaly. Same rule forauth_factor=RCCMwithout RCCM. - R-CP-019 - IF a link covers a customer with email AND the email channel is healthy at issuance time THEN the email variant is issued in addition to the WhatsApp + SMS variants. Email-channel failure does NOT block dispatch on the other channels.
- R-CP-020 - Customer-side tenant-status guard : every signed-link verify, every challenge submission, every customer-session lookup runs R-OP-017 first. IF
tenant.status != ACTIVETHEN return HTTP 503 withauth.tenant.suspended.customerregardless of underlying status (SUSPENDED/DELETED/PENDING). The tenant_id is resolved from the signed link's stored row, not from the URL - no information leak. - R-CP-021 - Agency disabled does NOT impact customer signed-link flow . A signed link issued for a customer file linked to agency A continues to verify and challenge normally even if A is disabled mid-cycle. Rationale: a disabled agency must still let its existing customers complete payment for already-dispatched campaigns. Operators of that agency (if any) keep historical read access; new actions on the disabled agency are blocked downstream (OP / ING / PAY), not in AUTH.
User management¶
- R-ADM-001 - A CABINET_ADMIN can create users with role
{CABINET_ADMIN, OPERATOR, SUPERVISOR, READ_ONLY}within their own tenant. They cannot create users in another tenant. - R-ADM-002 - User creation form requires: legal name, login (auto-generated from email/phone if not provided), role, agency scope (mandatory if role=OPERATOR), invitation channel, contact for that channel, MFA phone number, MFA WhatsApp-enabled flag.
- R-ADM-003 - Invitation channel SMS or WhatsApp uses the same one-time activation link payload as the email channel.
- R-ADM-004 - Invitation expires 7 days after creation. After expiry the user is moved to
INVITATION_EXPIREDstate; CABINET_ADMIN must re-invite. - R-ADM-005 - A CABINET_ADMIN cannot deactivate themselves. The platform admin (us) handles last-admin scenarios via TENANT module support tooling.
- R-ADM-006 - A SUPERVISOR or OPERATOR or READ_ONLY cannot create, modify, or deactivate users. Attempts are rejected
403 FORBIDDEN.
- R-ADM-007 - On
TenantOnboarded(tenantId, countryCode, groupId?, primaryContact, defaultAgencyId)(D-1, D-11): AUTH (a) creates a Keycloak Organization in thepapillonrealm withid = tenantIdand attributes{country_code: countryCode, group_id: groupId}if not present (idempotent), (b) creates a Keycloak user as a member of that Organization with login =primaryContact.emailand a randomized one-time password, (c) attaches Keycloak roleCABINET_ADMIN, (d) creates the projection row inuserswithtenant_id,role=CABINET_ADMIN,agency_scope=NULL,mfa_phone=primaryContact.phone,state=PENDING_ACTIVATION, (e) creates auser_invitationsrow (channel=email, 7-day TTL), (f) calls NOTIF to dispatch the activation invitation, (g) emitsinitial_cabinet_admin_provisioned. The handler is idempotent: on event redelivery, AUTH detects the existing Organization + user (matched bytenantId+tenant_id+username) and emits the same audit event withidempotent_skip=true. - R-ADM-008 - Platform-admin user lifecycle :
PLATFORM_ADMINusers are NOT created byCABINET_ADMIN(R-ADM-001). They are created out-of-band via OPS runbook (AUTH-OQ-09). They MAY be deactivated by anotherPLATFORM_ADMIN(analogous to R-OP-007 - using deactivation reason codes); the system MUST refuse the last-active-platform-admin deactivation (mirror of R-ADM-005). - R-ADM-009 - BYO-DB cross-DB integrity for
agency_scope: when aCABINET_ADMINassigns or modifiesagency_scopefor an OPERATOR (R-ADM-002 / AUTH-ADM-02), AUTH MUST resolve each agency UUID through theTenantConfigService(which routes to the BYO-DB or shared DB transparently) and reject any UUID that does not exist or whosestatus != ACTIVE. After persistence, AUTH does NOT re-validate on each token refresh in V1; this is documented as TV-07. ARCHITECT may strengthen.
Platform-admin auth (D-3 lightweight)¶
- R-PA-001 -
PLATFORM_ADMINis a 5th role in the same Keycloak realm as the four operator roles. The Keycloak user has NOtenant_idclaim attached.TenantContextresolution (R-OP-020) for aPLATFORM_ADMINrequest yieldstenantId=null,agencyScope=∅,country=null,role={PLATFORM_ADMIN}. - R-PA-002 - Authorization for
PLATFORM_ADMIN: cross-tenant control endpoints (anything under/api/control/...) accept the role; tenant-scoped endpoints under/api/tenant/me/...REJECT the role with403 FORBIDDEN. A platform admin who needs to operate as a tenant admin must do so via a delegated impersonation flow - V2 only; not available in V1. - R-PA-003 - MFA + sessions for
PLATFORM_ADMINuse the same primitives as operators (R-OP-002..R-OP-006 + R-OP-011..R-OP-013) with the step-up list extended per R-OP-010. No realm-per-platform-admin in V1.
B.2 State Machines¶
Operator user lifecycle¶
stateDiagram-v2
[*] --> PENDING_ACTIVATION: invited
PENDING_ACTIVATION --> ACTIVE: activation link clicked + password set + MFA phone set
PENDING_ACTIVATION --> INVITATION_EXPIRED: 7 days elapsed
INVITATION_EXPIRED --> PENDING_ACTIVATION: re-invited
ACTIVE --> LOCKED: 5 password failures OR MFA lockout
LOCKED --> ACTIVE: admin unlock
ACTIVE --> DEACTIVATED: admin deactivates (with reason code)
DEACTIVATED --> [*]
LOCKED --> DEACTIVATED: admin deactivates
INVITATION_EXPIRED --> [*]: admin abandons
Customer signed-link lifecycle¶
stateDiagram-v2
[*] --> ISSUED: campaign validated, dispatch scheduled
ISSUED --> CLICKED: customer clicks link
CLICKED --> CHALLENGE_PENDING: landing page rendered
CHALLENGE_PENDING --> AUTHENTICATED: DOB/RCCM correct
CHALLENGE_PENDING --> CHALLENGE_PENDING: DOB/RCCM wrong, attempts < 5
CHALLENGE_PENDING --> LOCKOUT: 5th wrong attempt
LOCKOUT --> CHALLENGE_PENDING: 30 min elapsed, cycle < 3
LOCKOUT --> PERMANENTLY_REVOKED: 30 min elapsed, cycle == 3
AUTHENTICATED --> AUTHENTICATED: contract view, partial payment
AUTHENTICATED --> AUTO_REVOKED_PAID: all eligible contracts paid
AUTHENTICATED --> EXPIRED: 72h TTL elapsed
CHALLENGE_PENDING --> EXPIRED: 72h TTL elapsed
LOCKOUT --> EXPIRED: 72h TTL elapsed (whichever first)
ISSUED --> EXPIRED: clicked after 72h
ISSUED --> AUTO_REVOKED_REISSUED: operator re-issues
CHALLENGE_PENDING --> AUTO_REVOKED_REISSUED: operator re-issues
LOCKOUT --> AUTO_REVOKED_REISSUED: operator re-issues
PERMANENTLY_REVOKED --> [*]: file flips to anomaly
AUTO_REVOKED_PAID --> [*]
AUTO_REVOKED_REISSUED --> [*]
EXPIRED --> [*]
Operator login session¶
stateDiagram-v2
[*] --> ANONYMOUS
ANONYMOUS --> PASSWORD_OK: valid password
ANONYMOUS --> ANONYMOUS: invalid password (counter++; lock at 5)
PASSWORD_OK --> AUTHENTICATED: remember-device cookie valid
PASSWORD_OK --> MFA_PENDING: no remember-device cookie
MFA_PENDING --> AUTHENTICATED: OTP correct
MFA_PENDING --> MFA_PENDING: OTP wrong (3 max)
MFA_PENDING --> ANONYMOUS: 3 OTP failures → MFA locked 15 min
AUTHENTICATED --> AUTHENTICATED: activity within 30 min
AUTHENTICATED --> ANONYMOUS: 30 min idle OR 8h absolute OR explicit logout OR forced kill
AUTHENTICATED --> STEP_UP_PENDING: sensitive action requested
STEP_UP_PENDING --> AUTHENTICATED: fresh OTP correct
STEP_UP_PENDING --> AUTHENTICATED: 3 OTP failures → action aborted, session preserved
B.3 Audit Events Emitted by AUTH¶
Every event below is emitted to the AUDIT module with the V1 PRD §18 base fields (event_id, timestamp, initiator, tenant_id, agency_id if applicable, result) PLUS the AUTH-specific fields listed.
| Event | Side | AUTH-specific fields |
|---|---|---|
operator_invited |
[ADMIN] | invited_user_id, invitation_channel (email | sms | whatsapp), role, agency_scope |
operator_activated |
[OPERATOR] | user_id, activation_age_seconds |
operator_invitation_expired |
[BACKEND] | invited_user_id |
operator_login_attempt |
[OPERATOR] | username, success (bool), failure_reason (BAD_PASSWORD | BAD_OTP | MFA_TIMEOUT | LOCKED | INACTIVE | DEACTIVATED), mfa_factor (PASSWORD_ONLY | SMS_OTP | WHATSAPP_OTP | REMEMBER_DEVICE | BYPASS_CODE), ip, user_agent |
operator_session_started |
[OPERATOR] | user_id, session_id |
operator_session_idle_timeout |
[OPERATOR] | user_id, session_id |
operator_session_absolute_timeout |
[OPERATOR] | user_id, session_id |
operator_session_explicit_logout |
[OPERATOR] | user_id, session_id |
AUTH_OPERATOR_SESSION_KILLED |
[BACKEND] | user_id, session_id, reason (OFFBOARDING | ADMIN_ACTION | SECURITY | TENANT_SUSPENDED | TENANT_SOFT_DELETED) |
operator_remember_device_issued |
[OPERATOR] | user_id, device_fingerprint_hash |
operator_remember_device_invalidated |
[BACKEND] | user_id, device_fingerprint_hash, reason |
operator_mfa_bypass_issued |
[ADMIN] | issued_for_user_id, issuing_admin_id |
operator_mfa_bypass_used |
[OPERATOR] | user_id, age_seconds |
operator_step_up_required |
[OPERATOR] | user_id, sensitive_action |
operator_step_up_outcome |
[OPERATOR] | user_id, sensitive_action, outcome (success | failure | aborted) |
operator_role_changed |
[ADMIN] | target_user_id, old_role, new_role, old_agency_scope, new_agency_scope |
operator_password_reset_initiated |
[OPERATOR] | user_id, channel |
operator_password_reset_completed |
[OPERATOR] | user_id |
operator_deactivated |
[ADMIN] | target_user_id, reason_code (DÉPART_VOLONTAIRE | LICENCIEMENT | FRAUDE_SUSPECTÉE | AUTRE) |
operator_dob_rccm_reveal |
[OPERATOR] | user_id, customer_file_id, field (dob | rccm) |
AUTH_LINK_ISSUED |
[BACKEND] | campaign_id, file_id, channels_dispatched (array), ttl_seconds, auth_factor (dob | rccm) |
AUTH_LINK_CLICKED |
[CUSTOMER] | campaign_id, file_id, channel_clicked (wa | sms | em), ip, user_agent, multi_device_click (bool) |
AUTH_LINK_REJECTED_EXPIRED |
[CUSTOMER] | campaign_id, file_id, age_seconds_past_ttl |
AUTH_LINK_REJECTED_REVOKED |
[CUSTOMER] | campaign_id, file_id, revoke_reason |
customer_auth_success |
[CUSTOMER] | campaign_id, file_id |
customer_auth_failure |
[CUSTOMER] | campaign_id, file_id, attempt_index, attempt_value_hash (NEVER the value itself) |
customer_auth_lockout_started |
[BACKEND] | campaign_id, file_id, cycle_index |
customer_auth_lockout_resumed |
[BACKEND] | campaign_id, file_id, cycle_index |
AUTH_LINK_PERMANENTLY_REVOKED |
[BACKEND] | campaign_id, file_id, reason (MAX_LOCKOUT_CYCLES) |
signed_link_auto_revoked_paid |
[BACKEND] | campaign_id, file_id |
signed_link_reissued |
[OPERATOR] | campaign_id, file_id, operator_user_id, previous_link_id |
AUTH_CROSS_TENANT_DENIED |
[BACKEND] | user_id, attempted_tenant_id, attempted_agency_id. Pairs with TENANT-PRD's PLATFORM_ADMIN_QUERY audit event (legitimate cross-tenant) - together they bracket all cross-tenant access attempts in the AUDIT module. |
initial_cabinet_admin_provisioned |
[BACKEND] | tenant_id, target_user_id, idempotent_skip (bool), invitation_id |
tenant_suspended_handled |
[BACKEND] | tenant_id, tokens_revoked_count, sessions_killed_count, remember_devices_invalidated_count, signed_links_frozen_count, customer_sessions_dropped_count, elapsed_ms |
tenant_reactivated_handled |
[BACKEND] | tenant_id, signed_links_unfrozen_count, keycloak_users_reenabled_count, elapsed_ms |
tenant_auth_purged |
[BACKEND] | tenant_id, users_anonymized, invitations_deleted, remember_devices_deleted, bypass_codes_deleted, signed_links_deleted, customer_attempts_deleted, keycloak_users_disabled |
AUTH_AGENCY_DISABLED_ACK |
[BACKEND] | tenant_id, agency_id (no scope mutation; informational only) |
platform_admin_step_up_required |
[ADMIN] | user_id, sensitive_action (one of TENANT_CREATE / TENANT_SUSPEND / etc., per R-OP-010 platform-admin list) |
platform_admin_step_up_outcome |
[ADMIN] | user_id, sensitive_action, outcome (success | failure | aborted) |
B.4 Decision Tables¶
Role × action access matrix¶
PLATFORM_ADMIN is cross-tenant; the column reflects access on the control-plane surface. Tenant-scoped endpoints (/api/tenant/me/...) reject PLATFORM_ADMIN with 403 (R-PA-002).
| Action | PLATFORM_ADMIN | CABINET_ADMIN | OPERATOR (in scope) | SUPERVISOR | READ_ONLY |
|---|---|---|---|---|---|
| Create / suspend / reactivate / soft-delete tenant (R-OP-010 step-up) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Edit tenant identity (legal name / NCC / agrément, R-OP-010 step-up) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Configure BYO-DB credentials (R-OP-010 step-up) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Upload / replace compliance docs (R-OP-010 step-up) | ✅ | ❌ | ❌ | ❌ | ❌ |
List / view all tenants (audited via PLATFORM_ADMIN_QUERY) |
✅ | ❌ (own tenant only) | ❌ | ❌ | ❌ |
| Read tenant config | n/a (cross-tenant) | ✅ | ✅ (subset) | ✅ | ✅ |
| Edit tenant branding / settings | ❌ (use TENANT identity edit instead) | ✅ | ❌ | ❌ | ❌ |
| Read customer files | ❌ (V1 - no impersonation) | ✅ all agencies | ✅ scoped | ✅ all agencies | ✅ all agencies |
| Edit customer files (correction workshop) | ❌ | ✅ | ✅ scoped | ❌ | ❌ |
| Validate campaign (R-OP-010 step-up) | ❌ | ✅ | ✅ scoped | ❌ | ❌ |
| Re-issue customer link | ❌ | ✅ | ✅ scoped | ❌ | ❌ |
| View KPI dashboards | ✅ platform-wide | ✅ tenant | scoped KPI | ✅ tenant-wide | ❌ |
| Reveal customer DOB/RCCM | ❌ (V1) | ✅ (audited) | ✅ scoped (audited) | ✅ (audited) | ❌ |
| Create user (R-OP-010 step-up) | ❌ (no PLATFORM_ADMIN self-create - OPS runbook only) |
✅ | ❌ | ❌ | ❌ |
| Change role / agency scope (R-OP-010 step-up) | ❌ | ✅ | ❌ | ❌ | ❌ |
| Deactivate user (R-OP-010 step-up) | ✅ (other PLATFORM_ADMIN, last-active guard R-ADM-008) | ✅ within tenant | ❌ | ❌ | ❌ |
| Issue MFA bypass | ❌ (no impersonation V1) | ✅ within tenant | ❌ | ❌ | ❌ |
| View AUTH events (file timeline) | ❌ (V1) | ✅ | ✅ scoped | ✅ | ✅ |
| View anomaly queue | ❌ (V1) | ✅ | ✅ scoped | ✅ | ✅ |
Customer challenge-failure decision¶
| Counter (after fail) | Cycle counter | Action |
|---|---|---|
| 1–4 / 5 | any | Show error, increment counter, allow retry |
| 5 / 5 | 0 → 1 | Enter 30-min lockout cycle 1 |
| 5 / 5 | 1 → 2 | Enter 30-min lockout cycle 2 |
| 5 / 5 | 2 → 3 | Permanently revoke link, promote file to anomaly, notify operator |
Operator MFA-OTP delivery decision¶
| SMS attempt | OTP validated? | WhatsApp-enabled? | Next action |
|---|---|---|---|
| 1 | ✅ | - | Open session |
| 1 | ❌ (60s timeout) | - | Allow resend → SMS attempt 2 |
| 2 | ✅ | - | Open session |
| 2 | ❌ (60s timeout) | yes | Allow resend → WhatsApp attempt |
| 2 | ❌ (60s timeout) | no | Show "demandez un code de secours à votre admin" |
| ✅ | - | Open session | |
| ❌ (60s timeout) | - | Show "demandez un code de secours à votre admin" | |
| Any | 3 wrong codes entered | - | MFA lock 15 min, audit failure_reason=BAD_OTP |
B.5 Data Requirements¶
Aligned with V1 PRD §27 entities. AUTH module owns or contributes the following:
users (control plane)¶
user_idUUID PKtenant_idUUID NULL - FK to tenants. NULL only forPLATFORM_ADMINusers (cross-tenant by design - R-PA-001). All other roles MUST carry a non-NULLtenant_id.usernameTEXT UNIQUE per tenantdisplay_nameTEXTemailTEXT NULLmfa_phoneTEXT NOT NULL - E.164 (V1:+225...)mfa_whatsapp_enabledBOOL DEFAULT FALSEroleENUM{PLATFORM_ADMIN, CABINET_ADMIN, OPERATOR, SUPERVISOR, READ_ONLY}NOT NULLagency_scopeUUID[] - NULL or empty for tenant-wide roles AND forPLATFORM_ADMIN(cross-tenant); non-empty for OPERATOR. Cross-DB integrity per R-ADM-009 / TV-07.stateENUM{PENDING_ACTIVATION, ACTIVE, LOCKED, INVITATION_EXPIRED, DEACTIVATED}NOT NULLtenant_frozenBOOL DEFAULT FALSE - set during the suspension cascade (R-OP-014); cleared on reactivation (R-OP-015). Independent ofstate.deactivated_at,deactivation_reason(DÉPART_VOLONTAIRE | LICENCIEMENT | FRAUDE_SUSPECTÉE | TENANT_SOFT_DELETED | AUTRE)password_failures_counterINT DEFAULT 0created_at,created_by,updated_at
The
password_hashand the deep credential lifecycle are owned by Keycloak; theuserstable is an application-side projection / shadow used for tenant scoping, agency scope, role, MFA phone. The source of truth for password is Keycloak.
user_invitations (control plane)¶
invitation_idUUID PKtenant_idUUID NOT NULLtarget_user_idUUID NOT NULLchannelENUM{email, sms, whatsapp}delivery_addressTEXT (email or phone, depending on channel)token_hashTEXT - server stores hash onlyissued_atTIMESTAMPTZexpires_atTIMESTAMPTZ - issued_at + 7 daysconsumed_atTIMESTAMPTZ NULLstateENUM{PENDING, CONSUMED, EXPIRED, REVOKED}
remember_device_cookies (control plane)¶
cookie_idUUID PKuser_idUUID NOT NULLdevice_fingerprint_hashTEXTissued_at,expires_at(issued_at + 15 days)revoked_atNULLlast_used_at
mfa_bypass_codes (control plane)¶
code_idUUID PKtarget_user_id,issuing_admin_idcode_hashTEXTissued_at,expires_at(15 min)consumed_atNULL
signed_links (control plane - referenced by CP)¶
link_idUUID PKtenant_id,agency_idcampaign_id,file_idauth_factorENUM{DOB, RCCM}token_hashTEXT - HMAC-derived, server stores hash; the cleartext token is in the URLissued_at,expires_at(issued_at + 72h)attempt_counterINT DEFAULT 0cycle_counterINT DEFAULT 0lockout_untilTIMESTAMPTZ NULLstateENUM{ACTIVE, LOCKED, AUTHENTICATED, AUTO_REVOKED_PAID, AUTO_REVOKED_REISSUED, PERMANENTLY_REVOKED, EXPIRED}previous_link_idUUID NULL - when re-issued, points to the parentchannels_dispatchedJSONB -[{channel, dispatched_at, channel_token}]last_clicked_at,last_clicked_channel,last_clicked_ip
Hot-path counters (
attempt_counter,lockout_until,cycle_counter) MAY be cached / fronted by Redis for write throughput - ARCHITECT decision. The Postgres row remains the system of record.
customer_auth_attempts (control plane - append-only)¶
Append-only attempt log feeding the audit. Per signed-link, per click.
attempt_idUUID PKlink_idUUID NOT NULLattempt_indexINTcycle_indexINTresultENUM{SUCCESS, FAILURE, LOCKOUT_TRIGGERED, REVOKE_TRIGGERED}attempt_value_hashTEXT - HMAC of submitted value, never the cleartextip,user_agentcreated_at
B.6 Multi-Tenant Implications¶
- Every row in
users,user_invitations,remember_device_cookies,mfa_bypass_codes,signed_links,customer_auth_attemptscarriestenant_id(andagency_idwhere applicable onsigned_links). No cross-tenant uniqueness assumptions. - Shared DB / shared schema profile: rows live in the control-plane database alongside the rest of the AUTH module's data. Every query MUST include
WHERE tenant_id = ?. - BYO-DB profile: AUTH module's user / invitation / remember-device / bypass tables remain in the platform control-plane DB (V1 has a single Keycloak realm and a single platform-side identity store) - they do NOT split into the tenant's BYO-DB. The customer-facing
signed_linksandcustomer_auth_attemptsMAY split into the tenant's BYO-DB or stay control-plane-side; ARCHITECT decides the exact placement, but the resolver contract is: given atenant_idfrom the JWT, the right datasource is picked. - A single Keycloak realm hosts all V1 tenants. JWT carries
tenant_idas a custom claim. Realm-per-tenant silo for enterprise tenants is V2-deferred and MUST be possible to introduce without schema migration on tenant-scoped tables. - Country profile: the operator's country is read from the tenant record (V1: always
CI). The AUTH module passescountry_codealong the request (via JWT or TenantContext) so downstream NOTIF can pick the right SMS / WhatsApp adapter for the OTP. AUTH itself has no per-country branch in V1.
- Cross-DB integrity (BYO-DB) (D-7):
usersrows live in the platform control-plane DB;agencyrows for a BYO-DB tenant live in the tenant's BYO-DB (per TENANT-PRD §B.5). Thereforeusers.agency_scope UUID[]carries cross-DB references. V1 stance: validate at write time only (R-ADM-009); do NOT re-validate on each token refresh.AgencyDisableddoes NOT mutate scope (R-OP-018). Mechanism for the write-time validation (TenantConfigService routing throughTenantAwareDataSource) is an ARCHITECT decision; this PRD only states the constraint. - Tenant-status cache (D-5): AUTH maintains a Redis-backed cache of
(tenant_id → status)consumed on every authenticated request (R-OP-017). Cache is invalidated on everyTenant{Suspended,Reactivated,SoftDeleted}Event(R-OP-014..R-OP-016). Acceptable staleness: 30 s (AUTH-NFR-13). Both V1 DB profiles use the same cache - the cache key includestenant_idonly. min_auth_level(D-8): V1 hardcodes Level 1 in AUTH. AUTH consumesTenantSettingsChangedonly to invalidate its tenant-config cache whenmin_auth_levelchanges - V1 has no Level 2 implementation, so the V1 cache invalidation is a forward-compat hook, not a behavior change.- Platform-admin cross-tenant queries (D-3 + D-10):
PLATFORM_ADMINrequests bypass the per-rowWHERE tenant_id = ?filter on theuserstable when listing all users for support purposes. Such queries write aPLATFORM_ADMIN_QUERYaudit entry (consumed by the AUDIT module - see TENANT-PRD §C.3 for the symmetric write ontenant). Unauthorized cross-tenant access from any non-PLATFORM_ADMINrole still producesAUTH_CROSS_TENANT_DENIEDper the original R-OP-021.
B.7 Cross-Module Dependencies¶
| AUTH ↔ Module | Direction | Contract |
|---|---|---|
| TENANT | consumes (events) | TenantOnboarded → R-ADM-007 / AUTH-ADM-04 (bootstrap initial CABINET_ADMIN). TenantSuspended → R-OP-014 / AUTH-ADM-05 (cascade kill). TenantReactivated → R-OP-015 / AUTH-ADM-06 (cascade restore). TenantSoftDeleted → R-OP-016 / AUTH-ADM-07 (anonymize + purge). AgencyDisabled → R-OP-018 (no-op + audit ack). TenantSettingsChanged → R-OP-019 (cache invalidation; specifically min_auth_level). |
| TENANT | consumes (queries) | Tenant existence + country + state cached via R-OP-017 (tenant-status guard). TenantConfigService for cross-DB agency lookup at write time (R-ADM-009). |
| TENANT | exposes | A platform-admin-callable API to re-trigger the AUTH-ADM-04 invitation if the original NOTIF dispatch failed (called from the TENANT detail screen). |
| NOTIF | consumes | SMS OTP dispatch, WhatsApp OTP dispatch, invitation-link delivery on email/SMS/WhatsApp, signed-link delivery on email/SMS/WhatsApp. The CABINET_ADMIN bootstrap invitation (D-1) is dispatched on the email channel only in V1. |
| AUDIT | publishes | All events listed in §B.3. The AUTH_CROSS_TENANT_DENIED event is symmetric with TENANT's PLATFORM_ADMIN_QUERY. |
| ING | exposes | auth_factor validation: ingestion checks every customer row carries the required challenge value (DOB or RCCM). Blocking anomaly on miss. |
| CP (customer-portal) | consumes | Signed-link issuance API, signed-link verification API, customer-session lookup API. CP NEVER touches Keycloak. CP MUST honor R-CP-020 (tenant-status guard) on every request. |
| OP (operator-console) | consumes | Operator login flow (Keycloak), session info, role/scope info via TenantContext, link-reissuance API, DOB/RCCM-reveal API. OP MUST gate new actions on disabled agencies per R-OP-018. |
| OP | consumes | At campaign validation time: bulk signed-link issuance API. Bulk issuance MUST honor R-OP-017 (no issuance for non-ACTIVE tenants). |
| PAY | consumes | Customer-session lookup (only an authenticated session can initiate a payment). PAY initiates payments only when the underlying signed link is tenant_frozen=false per R-OP-014. |
| BILL | none | No direct dependency. |
Part C - Constraints & Boundaries¶
C.1 Regulatory Compliance¶
CIMA Reg. 01-24 (digital distribution and management)¶
- Customer authentication scheme (AUTH-OQ-01): signed link + DOB/RCCM is implemented as the V1 customer auth. Acceptability under Reg. 01-24 for the renewal-consent + payment-authorization flow MUST be confirmed by a qualified lawyer before V1 production launch. Working position: this is "proportionate authentication" for a renewal that does not modify contract scope.
- Mandatory information surface on the customer auth landing page (AUTH-OQ-05): broker identity, agency phone, brief privacy notice, link to CGU. Article-anchored full list to be produced by the LEGAL_AUDITOR re-pass and delivered to the DESIGNER. The fields surfaced come from TENANT-PRD §B.4:
tenant.commercial_name,tenant.legal_name(footer / mentions légales),tenant.agrement_number(footer),tenant.logo_object_key(header),tenant.primary_color(header band),tenant.support_email(contact), andagency.phonefor the agency that owns the customer file. AUTH passes these as part of the customer landing context. - Audit trail: AUTH supplies the events needed to reconstruct
notification → authentication → paymentchronology (V1 PRD §16.5 / §18). See §B.3. - Technical service provider positioning: AUTH does not store or display anything that could let a third party perceive Papillon as the insurer or distributor; broker logo + agency phone are always shown above the fold on customer screens.
Côte d'Ivoire law n°2013-450 + ARTCI¶
- Lawful basis for AUTH data processing (AUTH-OQ-06):
- Operator MFA phone: necessary for the performance of the contract between the cabinet and the platform (B2B), to be confirmed.
- Customer DOB / RCCM: necessary for the performance of the insurance contract by the broker (controller); platform processes as sub-processor.
- Working position; subject to lawyer validation.
- Minimization: the AUTH module stores hashes of OTP codes, hashes of bypass codes, hashes of signed-link tokens, hashes of submitted challenge values - never cleartext.
- Retention (AUTH-OQ-02): durations TBD. Working V1 posture - keep all auth events indefinitely; ARCHITECT must implement schema in a way that supports a future purge job.
- Data subject rights: handled at the customer-record level by the tenant (controller). AUTH module exposes a query-by-customer-id API so a future right-of-access / right-to-erasure implementation can list and (where allowed) delete a customer's auth events.
- Cross-border data flow (AUTH-OQ-04): WhatsApp OTP fallback for operators sends the operator's phone number to Meta-hosted infrastructure outside CI/UEMOA. Transfer basis to be confirmed (DPA with NOTIF provider; ARTCI position on Meta T&C).
- DPO / correspondant (AUTH-OQ-07): trigger threshold on the platform publisher TBD.
CIMA cybersecurity regulation (010-24 or current equivalent) - AUTH-OQ-03¶
- Encryption-in-transit on every AUTH endpoint (TLS 1.2+).
- Encryption-at-rest for sensitive AUTH columns where the underlying Postgres deployment supports it.
- BCP / incident notification timing - TBD.
C.2 Localization¶
- Primary language: French (fr-FR) for every operator-facing and customer-facing string. Every string goes through i18n.
- Date format:
JJ/MM/AAAA(DOB input on the customer page; date display in operator console). - Phone format: E.164 stored, displayed
+225 XX XX XX XX. - Currency: XOF (relevant to AUTH only via the agency-phone displayed alongside payment summaries upstream of PAY). 0 decimals, space thousands separator - owned by the design system.
- CIMA-area French insurance vocabulary:
assuré,souscripteur,mandataire,intermédiaire,quittance,échéance,prime. Never "insured" or anglicisms in user-facing copy.
C.3 Security¶
- Realm + Organization placement : every operator user is provisioned in realm
papillonas a member of exactly one Keycloak Organization whose id equalstenant.id. Thetenant_idJWT claim is emitted by a protocol mapper from the Organization id (ortenant_idOrganization attribute, decision in AUTH arch).PLATFORM_ADMINusers live in the realm with no Organization membership. Keycloak Admin REST API touchpoints used by AUTH:POST /admin/realms/papillon/organizations(D-1),POST /admin/realms/papillon/organizations/{org}/members(AUTH-ADM-04),POST /admin/realms/papillon/organizations/{org}/identity-providers(AUTH-ADM-08, v0.0.5+). - Tenant isolation: enforced via
tenant_idon every AUTH table +WHERE tenant_id = ?on every query (rule R-OP-020/R-OP-021/R-OP-022). Cross-tenant access attempts are audit-logged (AUTH_CROSS_TENANT_DENIED). - Signed-link scheme: HMAC with a server-side secret (algorithm + secret rotation cadence are ARCHITECT decisions - keep the secret in env / secret store, never in code or DB). 72h TTL, attempt-limited (5/cycle, 3 cycles), anti-replay via per-link
token_hash+ per-attempt log. - No PII in URLs: the signed-link URL carries an opaque token only; it does not embed tenant ID, agency ID, customer ID, contract ID, DOB, or RCCM in cleartext.
- Encryption in transit: TLS 1.2+ on every endpoint, customer-facing AND operator-facing.
- Encryption at rest: passwords delegated to Keycloak. Hashes (OTP, bypass code, link token, submitted challenge value) stored hashed. ARCHITECT decides whether selected columns (
mfa_phone, customer DOB) get application-level field encryption beyond Postgres TDE. - Logging: every event in §B.3 is emitted; no event carries cleartext secrets, OTPs, or challenge values - only their hashes.
- Session security: HttpOnly + Secure + SameSite=Strict cookies for operator session and remember-device. Customer session is ALSO cookie-bound but additionally gated by the link token (bearer credential).
- Anti-replay on customer side: the link token is a one-of-many credential per file/campaign; per-attempt
attempt_value_hashlog defeats trivial replay; counters defeat brute force. - Anti-replay on operator MFA: SMS / WhatsApp OTP single-use, max 5-min lifetime.
- Tenant-status guard : every authenticated AUTH endpoint reads tenant status from a Redis-backed cache before any business logic. Cache is invalidated on
Tenant{Suspended,Reactivated,SoftDeleted}Eventwithin 30 s (AUTH-NFR-13). Operator side returns explicit French copy on suspension; customer side returns HTTP 503 with generic copy regardless of underlying status (no information leak per R-OP-017). - Cross-DB BYO-DB integrity :
users.agency_scopereferencesagency.idrows that, in BYO-DB profile, live in the tenant's database. V1 validates at write time only (R-ADM-009 / TV-07). The platform DB does NOT carry foreign-key constraints onagency_scope- the FK lives only in the tenant DB. ARCHITECT decides the routing mechanism. - Platform-admin queries :
PLATFORM_ADMINrequests bypassWHERE tenant_id = ?filters when listing across tenants for support purposes (mirrors TENANT-PRD §C.3 for thetenanttable). Each such bypass writes aPLATFORM_ADMIN_QUERYaudit entry. Step-up MFA required for cross-tenant mutating actions (R-OP-010 platform-admin list).
C.4 Connectivity (V1 PRD §30)¶
- Customer side is low-bandwidth + resumable, NOT offline-first.
- The signed-link landing + challenge page MUST keep the customer-portal initial-payload budget < 200 KB gzipped (AUTH-NFR-01). The challenge form is small (one date or one RCCM input + submit button); no JS framework should be loaded on this page solely for AUTH purposes - defer to CP module.
- TTL of 72h is intentionally generous to absorb multi-day outages (V1 PRD §10 + §30.2 now both align to 72h at V1 end-state; no expiration during V0 v0.0.0-v0.0.5 per V0 §5.4).
- A customer who loses connection MID-CHALLENGE: the challenge state is server-side; on reconnection, the customer can click the link again and resume - counter and cycle are preserved (R-CP-002). No client-side draft is required for the AUTH challenge (single-input form).
- A customer mid-session who loses connection: the session is bound to the link token, server-side; on reconnection, clicking the link returns to the contract view without re-challenge until session timeout (downstream of AUTH; sessionning is CP territory).
- SMS as fallback for invitation links AND for signed-link delivery - same payload, alternate channel (NOTIF).
- Operator side is online-first: AUTH endpoints assume connectivity. The minimum offline base from V1 PRD §20 does NOT include re-authentication offline.
C.5 Out of Scope (V1)¶
- ❌ TOTP authenticator app for operator MFA (V2).
- ❌ Hardware-key MFA (V3).
- ❌ Risk-based / adaptive authentication (V2).
- ❌ Customer Level-2 auth (signed link + secret + SMS OTP) - V2.
- ❌ Customer Level-3 auth (Q/A or recovery) - V3.
- ❌ Pre-registered / named mandataire for corporate customers (V2).
- ❌ Manual link revocation by operator (V2 - V1 has auto-revoke only).
- ❌ Re-issuance cap (V2).
- ❌ Dedicated cross-file "auth events" screen (V2 - V1 has only the per-file timeline + the anomaly view).
- ❌ Realm-per-tenant Keycloak silo (V2).
- ❌ Schema-per-tenant Postgres profile (V2 - V1 has shared DB and BYO-DB only).
- ❌ Federated SSO / SAML / external IdP (V3).
- ❌ Operator self-service tenant creation (V2 - V1 platform admin onboards the first CABINET_ADMIN).
- ❌ Multi-country tenant pool inside a single Keycloak realm without country-aware routing (V1 is CI-only).
- ❌ Cross-tenant user federation (V2/V3).
- ❌ A native mobile app for operators or customers (V1 PRD §30.4).
- ❌ Native passkeys / WebAuthn (V2 evaluation).
- ❌ Customer biometric authentication (V3).
- ❌ Public AUTH API for partner integration (V3).
Appendix - Key French Copy¶
| Key | Copy |
|---|---|
auth.operator.login.bad_credentials |
Identifiant ou mot de passe incorrect. |
auth.operator.login.account_inactive |
Compte non activé. Cliquez sur le lien d'invitation reçu. |
auth.operator.login.account_deactivated |
Compte désactivé. Contactez votre administrateur. |
auth.operator.login.account_locked |
Compte verrouillé après plusieurs tentatives. Contactez votre administrateur. |
auth.operator.mfa.prompt |
Entrez le code à 6 chiffres reçu par SMS. |
auth.operator.mfa.resend |
Renvoyer le code |
auth.operator.mfa.fallback_whatsapp |
Code envoyé par WhatsApp. |
auth.operator.mfa.no_fallback |
Demandez à votre administrateur un code de secours. |
auth.operator.session.idle_warning |
Vous serez déconnecté(e) dans 1 minute. Cliquez ici pour rester connecté(e). |
auth.operator.session.expired |
Session expirée. Veuillez vous reconnecter. |
auth.operator.invite.expired_or_used |
Lien d'invitation expiré ou déjà utilisé. Demandez un nouvel accès à votre administrateur. |
auth.customer.dob.prompt |
Pour accéder à vos contrats, entrez votre date de naissance. |
auth.customer.rccm.prompt |
Pour accéder aux contrats de {raisonSociale}, entrez le numéro RCCM de l'entreprise. |
auth.customer.challenge.failure |
La date saisie ne correspond pas. Vérifiez votre date de naissance et réessayez. Tentative {n}/5. |
auth.customer.challenge.failure_rccm |
Le numéro RCCM ne correspond pas. Vérifiez la valeur et réessayez. Tentative {n}/5. |
auth.customer.lockout |
Trop de tentatives. Réessayez dans 30 minutes. En cas de doute, contactez votre conseiller au {agencyPhone}. |
auth.customer.permanently_revoked |
Lien désactivé pour des raisons de sécurité. Contactez votre conseiller au {agencyPhone}. |
auth.customer.expired |
Ce lien a expiré. Contactez votre conseiller au {agencyPhone} pour recevoir un nouveau lien. |
auth.customer.revoked_paid |
Vous avez déjà payé tous les contrats de cette échéance. Merci. |
auth.customer.multi_device.warning |
(V2) Ce lien a déjà été ouvert sur un autre appareil. Si ce n'est pas vous, contactez votre conseiller. |
auth.tenant.suspended.customer |
Service temporairement indisponible. Contactez votre cabinet. |
auth.tenant.suspended.operator |
Cabinet suspendu. Contactez le support Papillon. |
auth.tenant.unavailable.operator |
Cabinet indisponible. Contactez le support Papillon. |
auth.platform_admin.step_up.required |
Action sensible : entrez le code à 6 chiffres reçu par SMS pour confirmer. |
End of identity-prd.md V1