Identity (AUTH) - Architecture V1¶
Module:
identity- story prefixAUTHPlane: Control Plane (com.altarys.papillon.pcs.controlplane.identity) Architect session: 2026-05-19 Status: approved by Emmanuel BLONVIA on 05/23/2026
0. V0 Envelope Deltas (ajout 2026-05-21)¶
Ce bloc est strictement additif. Il décrit comment l'architecture V1 d'AUTH ci-dessous est livrée progressivement à travers les sous-versions V0 (v0.0.0 -> v0.1.0), et acte explicitement les divergences temporaires entre le découpage V0 et la cible V1 (notamment le signed-link). Tout le contenu V1 ci-dessous (§1 à §N) reste la cible. Références :
docs/prd/prd-v0.md§ 4 (lignesCustomer auth+Operator auth), § 5.4 (signed-link spec V0), § 5.8 (stratégie Keycloak Organizations) et § 8 (mapping stories).
0.1 Découpage par sous-version¶
| Sous-version | Livrable AUTH vs cette architecture V1 | Story IDs |
|---|---|---|
| v0.0.0 | Realm Keycloak partagé papillon (>= 26.6) + Keycloak Organizations (une org par tenant, créée par le handler TenantOnboarded). Flow opérateur ROPC uniquement. Tables users, users_roles créées sur la platform DB (ADR AUTH-ADR-01 reste valide y compris en BYO). SignedLinkService cf. §0.2 ci-dessous. |
AUTH-001 |
| v0.0.1 | Émission + vérification du signed-link de bout en bout, branchée sur Customer Portal CP-002 (paiement). Hash SHA-256 stocké, PAS de HMAC, PAS d'expiration, PAS d'attempt-limit. Voir §0.2 pour la justification et l'écart vs §3.3 / §4 ci-dessous. | AUTH-002 |
| v0.0.2 | Challenge d'identité customer = 4 derniers chiffres du téléphone. Capture en self-service au premier accès si absent du fichier d'ingestion. Aucun OTP, aucun multi-canal. | AUTH-003 |
| v0.0.4 | Le TenantAwareDataSourceResolver (anchor D2) gagne sa branche BYO. La résolution AUTH côté opérateur reste sur la platform DB (ADR AUTH-ADR-01) ; seules les requêtes métier downstream traversent la BYO DB. Validation cross-DB users.agency_scope -> agency.id (D-7 du PRD Change Log) implémentée write-time uniquement. |
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 vers realm dédié possible pour un tenant avec politique de mot de passe / MFA divergente (cf. prd-v0.md § 5.8). Cible : Enterprise type NSIA. |
AUTH-005 |
| v0.1.0 | Convergence avec le V1 PRD §19 sur le signed-link : (a) expiration 48-72 h câblée, (b) attempt-limit appliqué (recommandation V0-OQ-02 = 5), (c) rate-limit Redis transverse (partagée avec CP-005 / NOTIF-004). Le passage à HMAC + anti-replay reste planifié V1 et n'arrive pas à v0.1.0. | AUTH-006 |
0.2 Divergence verrouillée vs §3.3 / §4 - SignedLinkService V0¶
L'architecture V1 ci-dessous (§3.3 "Signed-link APIs", §4 "Sequence diagrams") spécifie un signed-link HMAC + expiration 48-72 h + attempt-limit + anti-replay. La phase V0 livre une variante dégradée délibérée :
- Hash SHA-256 stocké côté serveur (table
signed_link_token (token_hash, customer_session_id, tenant_id, created_at)), comparaison constant-time. PAS de HMAC, donc pas de vérification sans accès DB. C'est un choix V0 ; il rend l'attaque par force brute purement liée à la rate-limit Redis (livrée v0.1.0). - Pas d'expiration entre v0.0.0 et v0.0.5. Les liens sont valides jusqu'à révocation manuelle (table même, colonne
revoked_at). L'expiration 48-72 h devient effective v0.1.0. - Pas d'attempt-limit appliqué entre v0.0.0 et v0.0.5. Counter en Postgres + optimistic locking acté côté schéma (anchor AUTH-ADR-03) mais non lu pour décision avant v0.1.0.
- Anti-replay : non livré en V0. Reste à V1.
Cette divergence est réversible sans migration : le passage à HMAC se fait en ajoutant la colonne hmac_signature à signed_link_token et en faisant cohabiter les deux schémas le temps que les liens en circulation s'éteignent. Le client front (CP) ne voit jamais le mécanisme.
Statut légal : la simplification SHA-256 / no-expiration / no-attempt-limit n'est pas couverte par le mémo v2 au-delà de l'observation que CIMA 001-2024 art. 26 admet le lien à usage unique sur le canal déclaré (
VÉRIFIÉ). Le red flag #5 (opposabilité civile du magic link comme PREUVE de consentement,INCERTAIN - avocat requis) reste bloquant indépendamment du découpage V0 ; toute story qui utiliserait le signed-link comme preuve (et non comme simple auth) doit porter[LEGAL-REVIEW].
0.3 Stratégie Keycloak Organizations (locked v0.0.0)¶
Le V1 PRD AUTH parle de "5 rôles + multi-tenant via custom claim tenant_id". La cible V0 raffine cette posture en s'appuyant sur le mécanisme Keycloak Organizations (disponible >= 26.6) :
- Realm partagé
papillonpour tous les tenants V0. Pas de realm-per-tenant (complexité opérationnelle inutile à cette échelle). - Une
KeycloakOrganizationpar tenant, créée automatiquement par le handlerTenantOnboarded(étend D-1 du PRD Change Log v2). Letenant_idest porté à la fois par le claim JWT (custom mapper) et par l'appartenance à l'org (canonical à terme). - Carve-out réservé : un tenant avec exigence de politique mot-de-passe ou MFA divergente peut être déplacé vers un realm dédié (hybride). Mécanisme non automatisé en V0 ; runbook manuel.
- IdP brokering par org (v0.0.5) : chaque org peut activer OIDC / SAML brokering ; découverte par domaine email à la connexion opérateur. Le claim
tenant_idcontinue d'être émis par le mapper même quand l'utilisateur vient d'un IdP fédéré. - Suivi Keycloak 26.7 : FGAP (fine-grained admin permissions) pour Organizations attendu ; permettra de déléguer l'admin org par tenant. Non bloquant pour v0.0.5.
0.4 Hors V0 (renvoyé à V1 ou plus tard)¶
- MFA WhatsApp OTP opérateur : entièrement reporté. Flag
feature.whatsapp.operator-mfa.enabled=falsecâblé dès v0.0.0 mais sans implémentation derrière. - HMAC + anti-replay sur signed-link : V1.
min_auth_level = 2: V2 ; la consommation deTenantSettingsChangedest câblée v0.0.0 mais la valeur reste figée à1.- Console de gestion
PLATFORM_ADMIN: pas d'UI en V0, provisioning out-of-band via ops runbook (identique au plan V1 du PRD). - Migration de tenant
SHARED -> BYOcôté AUTH : non concernée (AUTH reste sur platform DB) ; le scénario lui-même est V2.
Le découpage V0 ne lève aucun statut
INCERTAIN - avocat requisouPRÉLIMINAIREdu memo. Les notes[LEGAL-REVIEW]héritées du PRD AUTH et du V1 PRD restent en vigueur sur les stories correspondantes.
1. System Context¶
AUTH is the gate that every authenticated interaction in the platform passes through - on both sides:
- Operator side: Cabinet staff authenticate via Keycloak (password + SMS OTP MFA, optional WhatsApp fallback, 15-day remember-device). Keycloak-issued JWTs carry
tenant_id,agency_scope,country_code,role- validated by AUTH's JWT filter on every API request. - Customer side: End customers (assuré / mandataire) authenticate via a signed link + Level-1 challenge (DOB for individuals, RCCM for legal entities). No Keycloak account is created per insured. The signed-link token is the bearer credential.
- Control plane: Platform admins (
PLATFORM_ADMIN) use the same Keycloak realm and MFA primitives; their JWTs carry notenant_idand are accepted on/api/control/...endpoints only.
C4Context
title AUTH module - System Context
Person(operator, "Cabinet operator / admin", "Browser, operator console")
Person(customer, "End customer (assuré / mandataire)", "Mobile browser, WhatsApp/SMS link")
Person(platform_admin, "Papillon platform admin", "Browser, internal tools")
System_Boundary(platform, "Papillon Collection Solution") {
System(auth, "AUTH module", "Operator login + MFA; signed-link issuance + challenge; tenant-status guard; user management")
System(keycloak, "Keycloak (papillon-platform realm)", "Credential store + token issuer")
System(tenant, "TENANT module", "Tenant directory, agency, settings, lifecycle events")
System(notif, "NOTIF module", "SMS OTP, WhatsApp fallback, invitation delivery")
System(cp, "CP module", "Customer portal - calls SignedLinkService via Spring boundary")
System(op, "OP module", "Operator console - consumes TenantContext; calls link re-issue API")
System(audit, "AUDIT module", "Append-only event log")
System(pay, "PAY module", "Payment - emits AllContractsPaid → link auto-revoke")
}
Rel(operator, auth, "Login / MFA / user mgmt")
Rel(customer, auth, "Link click / DOB challenge", "via CP frontend")
Rel(platform_admin, auth, "Step-up auth / cabinet-admin reinvite")
Rel(auth, keycloak, "Direct Grant, admin API (revoke sessions, enable/disable users)")
Rel(auth, notif, "OTP dispatch, invitation delivery")
Rel(auth, tenant, "Consumes lifecycle events; reads tenant status + agency info")
Rel(cp, auth, "SignedLinkService (Spring module boundary)")
Rel(op, auth, "TenantContext + link re-issue API")
Rel(auth, audit, "All §B.3 events via outbox")
Rel(pay, auth, "AllContractsPaid → auto-revoke signed link")
1.1 Cross-module event subscriptions¶
AUTH consumes from TENANT (via Spring Modulith outbox / @ApplicationModuleListener):
| Event | Handler | Story |
|---|---|---|
TenantOnboarded |
Bootstrap initial CABINET_ADMIN | AUTH-ADM-04 / S6d |
TenantSuspended |
Cascade kill sessions + freeze links | AUTH-ADM-05 / S6b |
TenantReactivated |
Cascade restore | AUTH-ADM-06 / S6b |
TenantSoftDeleted |
Anonymize users + hard-delete auth rows | AUTH-ADM-07 / S6c |
AgencyDisabled |
Audit-acknowledge only (no scope mutation) | R-OP-018 / S6b |
TenantSettingsChanged |
Invalidate tenant-config cache (min_auth_level) |
R-OP-019 / S6a |
AUTH consumes from PAY: AllContractsPaid → auto-revoke signed link (S4).
AUTH publishes to AUDIT: all events listed in PRD §B.3 (see §7.8 below).
1.2 OQ-X1 resolution (PROGRESS.md cross-module)¶
AUTH consumes TenantOnboarded asynchronously via Spring Modulith @ApplicationModuleListener (AFTER_COMMIT delivery). The TENANT provisioning run transitions to ACTIVE before AUTH has provisioned the first CABINET_ADMIN - a brief window (typically < 5 s) exists where the tenant is ACTIVE but has no operator user. This is acceptable in V1: the platform admin sees this gap via the TENANT detail screen and can re-trigger the invitation. Document this window in the operator runbook.
2. Data Model¶
2.1 Database placement decision¶
All AUTH tables reside on the platform data source (platformDataSource / platformJdbcClient). Rationale:
- Authentication primitives (token hashes, session state, invitation tokens) are control-plane data - not tenant business data.
- The hot-path signed-link verification (< 50 ms p95, AUTH-NFR-11) must not cross DB boundaries.
- V2 realm-per-tenant silo can be introduced without migrating these tables.
- BYO-DB tenants benefit from auth data remaining under platform custody (DPA / data-processor positioning).
Cross-DB references (enforced at application layer only, no DDL FK):
- users.agency_scope UUID[] → agency.id (tenant DB) - validated at write time via TenantConfigService only (AUTH-TV-07).
- signed_links.agency_id → agency.id (tenant DB) - same rule.
2.2 Entity-relationship diagram¶
erDiagram
tenant ||--o{ users : "tenant_id (platform FK in shared-DB profile)"
tenant ||--o{ signed_links : "tenant_id"
users ||--o{ user_invitations : "target_user_id"
users ||--o{ remember_device_cookies : "user_id"
users ||--o{ mfa_bypass_codes : "target_user_id"
signed_links ||--o{ customer_auth_attempts : "link_id"
signed_links ||--o| signed_links : "previous_link_id (re-issue chain)"
users {
UUID user_id PK
UUID tenant_id "NULL for PLATFORM_ADMIN only"
TEXT keycloak_user_id "Keycloak subject UUID"
TEXT username "partial UNIQUE per tenant"
TEXT display_name "NULLed on soft-delete"
TEXT email "NULLed on soft-delete"
TEXT mfa_phone "E.164; NULLed on soft-delete"
BOOL mfa_whatsapp_enabled "DEFAULT false"
TEXT role "ENUM 5 values"
UUID[] agency_scope "NULL = tenant-wide; cross-DB ref"
TEXT state "ENUM 5 states"
BOOL tenant_frozen "DEFAULT false; suspension flag"
TIMESTAMPTZ deactivated_at
TEXT deactivation_reason
INT password_failures_counter
INT version "optimistic lock"
TIMESTAMPTZ created_at
UUID created_by
TIMESTAMPTZ updated_at
}
user_invitations {
UUID invitation_id PK
UUID tenant_id NOT_NULL
UUID target_user_id NOT_NULL
TEXT channel "email | sms | whatsapp"
TEXT delivery_address
TEXT token_hash "SHA-256 of invitation token"
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at "issued_at + 7 days"
TIMESTAMPTZ consumed_at
TEXT state "PENDING | CONSUMED | EXPIRED | REVOKED"
}
remember_device_cookies {
UUID cookie_id PK
UUID user_id NOT_NULL
UUID tenant_id NOT_NULL
TEXT device_fingerprint_hash "SHA-256(UA + hashed-IP-block)"
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at "issued_at + 15 days"
TIMESTAMPTZ revoked_at "NULL = active"
TIMESTAMPTZ last_used_at
}
mfa_bypass_codes {
UUID code_id PK
UUID target_user_id NOT_NULL
UUID issuing_admin_id NOT_NULL
TEXT code_hash "SHA-256 of 6-digit code"
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at "issued_at + 15 min"
TIMESTAMPTZ consumed_at
}
signed_links {
UUID link_id PK
UUID tenant_id NOT_NULL
UUID agency_id NOT_NULL "cross-DB ref"
UUID campaign_id NOT_NULL
UUID file_id NOT_NULL
TEXT auth_factor "DOB | RCCM"
TEXT token_hash "SHA-256 of 32-byte random token - UNIQUE"
TEXT challenge_hmac "HMAC-SHA256(secret, normalizedValue || fileId) - supplied by ING/OP at issuance"
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at "issued_at + 72h"
INT attempt_counter "DEFAULT 0"
INT cycle_counter "DEFAULT 0"
TIMESTAMPTZ lockout_until
TEXT state "ISSUED|CLICKED|AUTHENTICATED|LOCKED|PERMANENTLY_REVOKED|AUTO_REVOKED_PAID|AUTO_REVOKED_REISSUED|EXPIRED"
BOOL tenant_frozen "DEFAULT false"
UUID previous_link_id "re-issue chain"
JSONB channels_dispatched "[{channel, dispatched_at}]"
TIMESTAMPTZ last_clicked_at
TEXT last_clicked_channel
INET last_clicked_ip
INT version "optimistic lock"
TIMESTAMPTZ created_at
}
customer_auth_attempts {
UUID attempt_id PK
UUID link_id NOT_NULL
INT attempt_index
INT cycle_index
TEXT result "SUCCESS | FAILURE | LOCKOUT_TRIGGERED | REVOKE_TRIGGERED"
TEXT attempt_value_hash "HMAC of submitted value; never cleartext"
INET ip
TEXT user_agent
TIMESTAMPTZ created_at
}
Note on signed_links.challenge_hmac: The customer's DOB/RCCM value is owned by ING. At link issuance time, the caller (OP/CAMP) passes HMAC-SHA256(challengeHmacSecret, normalizedValue || fileId). AUTH stores this and uses it for comparison at challenge time without ever holding the raw value. This satisfies AUTH-NFR-11 (< 50 ms p95) by avoiding a cross-module call on the hot path.
2.3 Indexing strategy¶
| Table | Index | Rationale |
|---|---|---|
users |
(tenant_id) |
Per-tenant user list |
users |
UNIQUE (tenant_id, username) WHERE tenant_id IS NOT NULL |
Partial unique - PLATFORM_ADMIN rows (tenant_id=NULL) allowed |
users |
(tenant_id, state) |
Filter by state within tenant (suspension cascade) |
user_invitations |
UNIQUE (token_hash) |
Token lookup on activation |
user_invitations |
(tenant_id, target_user_id) |
Re-invite check |
remember_device_cookies |
(user_id) WHERE revoked_at IS NULL |
Active cookies per user - partial index |
remember_device_cookies |
(tenant_id) WHERE revoked_at IS NULL |
Bulk revoke by tenant (suspension cascade) |
signed_links |
UNIQUE (token_hash) |
Hot-path verification (< 50 ms p95) |
signed_links |
(tenant_id, file_id) |
Operator lookup by file |
signed_links |
(tenant_id) WHERE state NOT IN ('PERMANENTLY_REVOKED','AUTO_REVOKED_PAID','AUTO_REVOKED_REISSUED','EXPIRED') |
Active links per tenant (suspension cascade) |
customer_auth_attempts |
(link_id, created_at DESC) |
Attempt history per link |
3. API Design¶
All endpoints follow docs/standards/api-guidelines.md. Response envelope: {"data": ..., "meta": ...} for success; {"error": {"code": "...", "message": "..."}} for errors.
3.1 Operator authentication¶
POST /api/auth/operator/login
POST /api/auth/operator/mfa/verify
POST /api/auth/operator/mfa/resend
POST /api/auth/operator/step-up/initiate
POST /api/auth/operator/step-up/verify
POST /api/auth/operator/logout
POST /api/auth/operator/refresh
GET /api/auth/operator/me
POST /api/auth/operator/password-reset/request
POST /api/auth/operator/password-reset/confirm
POST /api/auth/operator/login
// Request
{ "username": "string", "password": "string", "deviceToken": "string|null" }
// Response 200 - MFA pending
{
"data": { "mfaRequired": true, "mfaSessionId": "uuid", "otpChannel": "sms", "expiresAt": "ISO8601" }
}
// Response 200 - remember-device valid, session opened
{
"data": {
"mfaRequired": false,
"accessToken": "...", "refreshToken": "...", "expiresIn": 300,
"user": { "userId": "...", "displayName": "...", "role": "...", "tenantId": "...", "agencyScope": [] }
}
}
// Response 401
{ "error": { "code": "auth.operator.login.bad_credentials", "message": "Identifiant ou mot de passe incorrect." } }
POST /api/auth/operator/mfa/verify
// Request
{ "mfaSessionId": "uuid", "otp": "string", "rememberDevice": true }
// Response 200
{
"data": { "accessToken": "...", "refreshToken": "...", "expiresIn": 300, "deviceToken": "string|null", "user": { ... } }
}
3.2 Operator user management¶
GET /api/auth/operator/users ?cursor=&limit=25&state=ACTIVE
POST /api/auth/operator/users (CABINET_ADMIN; step-up USER_CREATE)
GET /api/auth/operator/users/{userId}
PUT /api/auth/operator/users/{userId} (CABINET_ADMIN; step-up USER_ROLE_CHANGE)
POST /api/auth/operator/users/{userId}/deactivate (CABINET_ADMIN; step-up USER_DEACTIVATE)
POST /api/auth/operator/users/{userId}/reinvite
POST /api/auth/operator/users/{userId}/mfa-bypass (CABINET_ADMIN)
User list item (compact):
{ "userId": "...", "displayName": "...", "role": "OPERATOR", "state": "ACTIVE", "agencyScope": ["..."], "lastLoginAt": "ISO8601" }
Cursor pagination (D6): { "data": [...], "meta": { "cursor": "...", "hasMore": true } }.
3.3 Signed-link APIs¶
POST /api/internal/auth/links (bulk issuance - called by OP/CAMP)
POST /api/auth/operator/files/{fileId}/links/reissue (operator re-issue)
POST /api/auth/operator/files/{fileId}/dob-rccm/reveal (audited reveal)
POST /api/internal/auth/links - bulk issuance (internal, not exposed to frontend):
// Request
{
"links": [
{
"fileId": "uuid", "campaignId": "uuid", "tenantId": "uuid", "agencyId": "uuid",
"authFactor": "DOB", "challengeHmac": "...", "ttlSeconds": 259200
}
]
}
// Response 200
{
"data": {
"issued": [ { "fileId": "...", "linkId": "...", "token": "..." } ],
"blocked": [ { "fileId": "...", "reason": "MISSING_DOB" } ]
}
}
3.4 Customer-facing APIs (served via CP module boundary)¶
The URL shape /api/portail/{token}/... is owned by the CP module (D5). CP backend controllers call AUTH's SignedLinkService and ChallengeVerificationService via Spring module boundary (in-process bean call). The REST mapping:
GET /api/portail/{token} → SignedLinkService.verifyLink(token, channel)
POST /api/portail/{token}/challenge → ChallengeVerificationService.submit(token, value)
GET /api/portail/{token}/session → CustomerSessionService.getStatus(token, sessionToken)
Link verification response (includes branding context):
{
"data": {
"status": "VALID",
"authFactor": "DOB",
"brandingContext": {
"commercialName": "Cabinet Alpha Assurances",
"logoUrl": "https://cdn.papillon.ci/logos/...",
"primaryColor": "#1A3A6A",
"agencyPhone": "+225 07 07 07 07",
"supportEmail": "[email protected]",
"legalName": "Cabinet Alpha Assurances SARL",
"agrementNumber": "N-2023-CI-0042"
},
"sessionToken": null
}
}
3.5 Platform-admin endpoints¶
POST /api/control/auth/tenants/{tenantId}/cabinet-admin/reinvite (PLATFORM_ADMIN; step-up TENANT_PROVISION_RETRY)
4. Multi-Tenant Strategy¶
4.1 Tenant resolution¶
| Context | Mechanism | TenantContext produced |
|---|---|---|
| Operator request | OperatorTenantResolutionFilter extracts tenant_id, agency_scope, country_code, papillon_role from JWT custom claims |
TenantContext(tenantId, agencyScope, countryCode, role) |
| PLATFORM_ADMIN | Same filter - detects role=PLATFORM_ADMIN + null tenant_id claim |
TenantContext.platformAdmin() - sentinel |
| Customer request | tenant_id resolved from signed_links.tenant_id keyed by token_hash (platform DB lookup) |
SignedLinkContext(tenantId, agencyId, fileId, campaignId) - not a TenantContext |
4.2 Database routing¶
AUTH uses only the platform data source:
@Repository
class UserRepository {
private final JdbcClient platformJdbcClient; // @Qualifier("platformJdbcClient")
List<UserRow> findByTenantId(UUID tenantId) {
return platformJdbcClient
.sql("SELECT * FROM users WHERE tenant_id = :tenantId")
.param("tenantId", tenantId)
.query(UserRow.class).list();
}
}
AUTH calls TenantConfigService (TENANT module, routes through TenantAwareDataSource) only for:
1. Agency scope validation at write time (R-ADM-009 / AUTH-TV-07)
2. Branding context assembly (commercial_name, logo_object_key, primary_color, support_email, legal_name, agrement_number, agency.phone)
4.3 Agency scope validation (BYO-DB cross-DB integrity)¶
for (UUID agencyId : agencyScope) {
Agency agency = tenantConfigService.resolveAgency(tenantId, agencyId);
// Routes to BYO-DB or shared tenant DB transparently
if (agency == null || agency.status() != AgencyStatus.ACTIVE) {
throw new InvalidAgencyScopeException(agencyId);
}
}
// AUTH-TV-07: no re-validation on token refresh in V1
4.4 Tenant-status cache¶
Key: auth:tenant-status:{tenantId}
Value: ACTIVE | SUSPENDED | DELETED | PENDING
TTL: 30 s (safety net - invalidated earlier by lifecycle events)
Miss: read from platform DB → repopulate cache
Invalidation is the FIRST action in every tenant lifecycle handler:
@ApplicationModuleListener
void on(TenantSuspended e) {
tenantStatusCache.invalidate(e.tenantId()); // ← FIRST: immediate effect on all new requests
tenantStatusCache.set(e.tenantId(), SUSPENDED, Duration.ofSeconds(30));
// ... then cascade kills ...
}
4.5 PLATFORM_ADMIN cross-tenant queries¶
PLATFORM_ADMIN requests carry TenantContext.platformAdmin(). Repositories detect the sentinel:
- Skip WHERE tenant_id = ? filter
- Write a PLATFORM_ADMIN_QUERY audit entry for every cross-tenant read
Non-PLATFORM_ADMIN cross-tenant attempts → cross_tenant_access_denied audit + 403 response.
4.6 V2 forward-compatibility notes¶
- Realm-per-tenant (V2): users are already tagged with
tenant_idas a Keycloak user attribute; migration would move them to a per-tenant realm. No schema change onusersrequired. - No global UNIQUE on
username: partial uniqueWHERE tenant_id IS NOT NULLallows future group structures. country_codeon tenant (from TENANT module) is already propagated throughTenantContextin V1, enabling per-country routing in V2 without AUTH schema changes.
5. Connectivity & Resumability¶
5.1 Signed-link resumability (customer side)¶
- TTL 72h: absorbs multi-day outages (V1 PRD §30 - "48–72h").
- Server-side state:
attempt_counter,cycle_counter,lockout_untilonsigned_links. Postgres is the system of record. - Optimistic concurrency on counter updates (prevents double-increment on concurrent multi-device clicks):
UPDATE signed_links
SET attempt_counter = attempt_counter + 1, version = version + 1
WHERE link_id = :linkId AND version = :expectedVersion;
-- 0 rows updated → OptimisticLockException → retry with fresh read
- Session resumability: the signed-link token IS the bearer credential. Customer loses connection mid-session → clicks the same link → server-side session found via
token_hash→ no re-challenge until session timeout (CP's responsibility). - In-flight payment during TTL expiry (R-CP-007c): payment completes via PAY's idempotency key; next link click returns
EXPIRED- no further interaction on the link. - Mid-challenge connection drop: challenge state is server-side. Customer reconnects, clicks link, sees the challenge form again at the current counter. No client-side draft needed (single-input form).
5.2 Operator side¶
Operator auth is online-first. No offline authentication. Session expiry (idle 30 min + absolute 8h) is enforced server-side and communicated to the frontend via polling / SSE last-activity tracking.
5.3 Bandwidth budget (customer side)¶
AUTH's contribution to the customer-portal critical-path payload: - Token verification response (branding context + auth factor): ≈ 500 bytes JSON - Challenge submission request/response: ≈ 300 bytes round-trip - Total AUTH contribution: < 1 KB - well within the 200 KB gzipped budget (AUTH-NFR-01)
CP module is responsible for the remaining payload (contract list, CSS, JS bundle).
6. Sequence Diagrams¶
6.1 Operator login - happy path (password + SMS OTP + remember-device)¶
sequenceDiagram
participant FE as Operator Frontend
participant AUTH as AUTH controller
participant KC as Keycloak
participant Redis as Redis
participant NOTIF as NOTIF module
participant DB as Platform DB
participant AUDIT as AUDIT module
FE->>AUTH: POST /auth/operator/login {username, password, deviceToken?}
AUTH->>Redis: GET auth:tenant-status:{tenantId} [status guard]
AUTH->>KC: Direct Grant POST /token {grant_type=password, username, password}
KC-->>AUTH: {access_token, refresh_token}
AUTH->>DB: SELECT remember_device_cookies WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
alt Remember-device cookie valid
AUTH-->>FE: 200 {mfaRequired:false, accessToken, refreshToken, expiresIn:300, user}
AUTH-)AUDIT: operator_login_attempt(success=true, mfa_factor=REMEMBER_DEVICE)
else MFA required
AUTH->>Redis: SET mfa:{mfaSessionId} = {tokensEncrypted, otpHash, attempts:0} TTL=300s
AUTH->>NOTIF: dispatch SMS OTP (6-digit, 5-min TTL)
AUTH-->>FE: 200 {mfaRequired:true, mfaSessionId, otpChannel:"sms"}
FE->>AUTH: POST /auth/operator/mfa/verify {mfaSessionId, otp, rememberDevice:true}
AUTH->>Redis: GET mfa:{mfaSessionId} → hash-compare OTP
AUTH->>DB: INSERT remember_device_cookies (if rememberDevice=true)
AUTH-->>FE: 200 {accessToken, refreshToken, expiresIn:300, deviceToken}
AUTH-)AUDIT: operator_login_attempt(success=true, mfa_factor=SMS_OTP)
AUTH-)AUDIT: operator_session_started(userId, sessionId)
AUTH-)AUDIT: operator_remember_device_issued(userId, deviceFingerprintHash)
end
6.2 Operator login - network drop during MFA¶
sequenceDiagram
participant FE as Operator Frontend
participant AUTH as AUTH controller
participant Redis as Redis
FE->>AUTH: POST /auth/operator/login → 200 {mfaRequired:true, mfaSessionId}
Note over FE: Network drops
Note over FE: Recovers within 5 min (OTP + session still in Redis)
FE->>AUTH: POST /auth/operator/mfa/verify {mfaSessionId, otp}
AUTH->>Redis: GET mfa:{mfaSessionId} - still valid (TTL 5 min)
AUTH-->>FE: 200 {accessToken, ...}
Note over AUTH: If OTP TTL expired (> 5 min): mfaSessionId not found → 401; FE prompts re-login
6.3 Customer signed-link click + DOB challenge - happy path¶
sequenceDiagram
participant Mob as Customer Browser (mobile)
participant CP as CP Backend
participant AUTH as SignedLinkService
participant DB as Platform DB
participant TENANT as TenantConfigService
participant AUDIT as AUDIT module
Mob->>CP: GET /portail/{token}?ch=sms
CP->>AUTH: verifyLink(token, channel="sms")
AUTH->>DB: SELECT signed_links WHERE token_hash = SHA256(token) [UNIQUE index]
AUTH->>DB: GET auth:tenant-status:{tenantId} [Redis cache]
AUTH->>DB: UPDATE signed_links SET last_clicked_at=NOW(), state='CLICKED', version++ [optimistic]
AUTH->>TENANT: getBrandingContext(tenantId, agencyId)
AUTH-)AUDIT: signed_link_clicked(campaignId, fileId, channel="sms", ip, ua, multiDevice=false)
AUTH-->>CP: {status:VALID, authFactor:DOB, brandingContext, sessionToken:null}
CP-->>Mob: 200 {landing page - branding + challenge form}
Mob->>CP: POST /portail/{token}/challenge {value:"15/08/1985"}
CP->>AUTH: submitChallenge(token, "15/08/1985")
AUTH->>DB: SELECT signed_links WHERE token_hash=? FOR UPDATE
Note over AUTH: Normalize "15/08/1985" → "1985-08-15"; compute HMAC; compare against challenge_hmac
AUTH->>DB: UPDATE signed_links SET state='AUTHENTICATED', version++
AUTH->>DB: INSERT customer_auth_attempts (result=SUCCESS, attempt_value_hash=HMAC(submitted))
AUTH-)AUDIT: customer_auth_success(campaignId, fileId)
AUTH-->>CP: {status:AUTHENTICATED, sessionToken:"session-uuid"}
CP-->>Mob: 200 {redirect to contract view}
6.4 Customer challenge - lockout path (5th wrong attempt)¶
sequenceDiagram
participant Mob as Customer Browser
participant AUTH as ChallengeVerificationService
participant DB as Platform DB
participant AUDIT as AUDIT module
Mob->>AUTH: submitChallenge(token, "16/08/1985") [4th attempt, wrong]
AUTH->>DB: SELECT ... FOR UPDATE (attempt_counter=3 → 4, version++)
AUTH-)AUDIT: customer_auth_failure(attempt_index=4)
AUTH-->>Mob: 422 {code:"auth.customer.challenge.wrong", message:"Tentative 4/5."}
Mob->>AUTH: submitChallenge(token, "17/08/1985") [5th, wrong]
AUTH->>DB: SELECT ... FOR UPDATE
Note over AUTH: attempt_counter 4→5; cycle_counter 0→1; lockout_until=NOW()+30min; state='LOCKED'
AUTH->>DB: UPDATE signed_links SET attempt_counter=5, cycle_counter=1, lockout_until=..., state='LOCKED', version++
AUTH->>DB: INSERT customer_auth_attempts (result=LOCKOUT_TRIGGERED)
AUTH-)AUDIT: customer_auth_lockout_started(cycle_index=1)
AUTH-->>Mob: 429 {code:"auth.customer.lockout", message:"Trop de tentatives. Réessayez dans 30 minutes.", retryAfter:"ISO8601"}
6.5 Tenant suspension cascade¶
sequenceDiagram
participant TENANT as TENANT module
participant HANDLER as AUTH event handler
participant Redis as Redis
participant DB as Platform DB
participant KC as Keycloak Admin API
participant AUDIT as AUDIT module
TENANT->>HANDLER: [outbox] TenantSuspended{tenantId, reasonCode, suspendedAt}
Note over HANDLER: Target: < 30 s p95
HANDLER->>Redis: DEL auth:tenant-status:{tenantId} ← FIRST (immediate effect on new requests)
HANDLER->>Redis: SET auth:tenant-status:{tenantId}=SUSPENDED TTL=30s
HANDLER->>DB: UPDATE users SET tenant_frozen=true WHERE tenant_id=? AND state != 'DEACTIVATED'
HANDLER->>DB: UPDATE remember_device_cookies SET revoked_at=NOW() WHERE tenant_id=? AND revoked_at IS NULL
HANDLER->>DB: UPDATE signed_links SET tenant_frozen=true WHERE tenant_id=? AND state IN ('ISSUED','CLICKED','AUTHENTICATED','LOCKED')
par Keycloak revoke (async, best-effort)
HANDLER->>KC: POST /admin/realms/{realm}/users/{userId}/logout (per active user)
end
HANDLER->>DB: DROP all customer sessions for this tenant
HANDLER-)AUDIT: tenant_suspended_handled{tokensRevokedCount, sessionsKilledCount, rememberDevicesInvalidatedCount, signedLinksFrozenCount, customerSessionsDroppedCount, elapsedMs}
6.6 TenantOnboarded - bootstrap initial CABINET_ADMIN¶
sequenceDiagram
participant TENANT as TENANT module
participant HANDLER as AUTH event handler
participant KC as Keycloak Admin API
participant DB as Platform DB
participant NOTIF as NOTIF module
participant AUDIT as AUDIT module
TENANT->>HANDLER: [outbox] TenantOnboarded{tenantId, primaryContact, defaultAgencyId}
HANDLER->>DB: SELECT users WHERE tenant_id=? AND role='CABINET_ADMIN' [idempotency check]
alt User already exists (event redelivery)
HANDLER-)AUDIT: initial_cabinet_admin_provisioned(idempotent_skip=true)
else First delivery
HANDLER->>KC: POST /admin/realms/{realm}/users {username:email, attributes:{tenant_id, role}}
KC-->>HANDLER: {keycloakUserId}
HANDLER->>KC: PUT /role-mappings/realm → assign CABINET_ADMIN role
HANDLER->>DB: INSERT users {tenant_id, keycloak_user_id, email, mfa_phone, role:CABINET_ADMIN, state:PENDING_ACTIVATION}
HANDLER->>DB: INSERT user_invitations {channel:email, token_hash, expires_at:NOW()+7d, state:PENDING}
HANDLER->>NOTIF: dispatch activation invitation (email)
HANDLER-)AUDIT: initial_cabinet_admin_provisioned(idempotent_skip=false, targetUserId, invitationId)
end
Note over HANDLER: Brief gap window (typically < 5 s): tenant is ACTIVE but CABINET_ADMIN is PENDING_ACTIVATION.<br/>Platform admin can re-trigger via POST /api/control/auth/tenants/{id}/cabinet-admin/reinvite if NOTIF fails.
7. Security¶
7.1 Signed-link token scheme¶
// Generation
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); // ~43 chars, URL-safe
String tokenHash = sha256Hex(token); // stored in signed_links.token_hash (UNIQUE index)
// Verification (hot path < 50 ms p95)
// SHA-256 is one-way; hash equality is sufficient - no constant-time compare needed
SELECT * FROM signed_links
WHERE token_hash = :hash
AND expires_at > NOW()
AND state NOT IN ('PERMANENTLY_REVOKED','AUTO_REVOKED_PAID','AUTO_REVOKED_REISSUED')
AND tenant_frozen = false;
Anti-replay: per-attempt customer_auth_attempts log; attempt counter on signed_links enforced via optimistic locking. Submitted challenge values logged as HMAC-SHA256(challengeSecret, submittedValue || linkId) - never cleartext.
Channel variant (?ch=wa|sms|em): channel parameter is logged only; does not affect the token hash or verification.
7.2 Keycloak configuration¶
| Parameter | Value | Rationale |
|---|---|---|
| Realm | papillon-platform |
Single realm, all V1 tenants |
| Backend client | papillon-backend (confidential) |
Backend controls credentials |
| Grant type | Direct Grant (password) | Full UX control; see ADR-AUTH-04 |
| Access token TTL | 5 minutes | Fast propagation of role/tenant changes (Decision #4) |
| Refresh token TTL | 30 minutes | Standard sliding |
| Absolute session | 8 hours | AUTH-NFR-04 |
| Custom claim mappers | tenant_id, agency_scope[], country_code, papillon_role |
Set from Keycloak user attributes |
| Password policy | min 8 chars, lock after 5 failures | R-OP-013 |
7.3 JWT filter chain (every operator API request)¶
1. Extract Bearer token from Authorization header
2. Verify JWT signature against Keycloak JWKS (cached in-process; refresh on 401 upstream)
3. Verify exp > now + clock skew 30 s
4. Extract papillon_role claim
5. If role = PLATFORM_ADMIN AND tenant_id claim is null → set TenantContext.platformAdmin()
6. Else extract tenant_id
7. Check Redis: GET auth:tenant-status:{tenantId}
SUSPENDED → 403 auth.tenant.suspended.operator
DELETED → 410
PENDING → 503
MISS → read platform DB, repopulate Redis (TTL 30s), recheck
8. Build TenantContext(tenantId, agencyScope, countryCode, role)
9. Set SecurityContextHolder
7.4 Remember-device cookie¶
Cookie name: papillon_device
Flags: HttpOnly; Secure; SameSite=Strict; Max-Age=1296000 (15 days)
Value: Base64url( cookieId || HMAC-SHA256(cookieSigningSecret, cookieId || userId || issuedAt) )
Server-side: remember_device_cookies row; revoked on deactivation / tenant suspension
Device fingerprint hash: SHA-256(userAgent + "/" + first_two_octets_of_ip) - coarse binding that survives legitimate IP changes without being trivially spoofable across devices.
7.5 MFA bypass code security¶
- Delivery: shown on screen to CABINET_ADMIN + SMS-sent to CABINET_ADMIN's own
mfa_phone- never to the locked-out operator directly (R-OP-007). - Storage:
SHA-256(code)inmfa_bypass_codes.code_hash; plaintext never persisted. - Lifecycle: single-use;
expires_at = issued_at + 15 min.
7.6 Step-up MFA¶
A step-up OTP is a fresh 6-digit SMS code (5-min TTL, single-use). The step-up session (Redis key) is independent of the main session - the main session is preserved even on step-up abort. The action is simply cancelled, not the session.
Full step-up action list (R-OP-010): CAMPAIGN_VALIDATE, USER_CREATE, USER_ROLE_CHANGE, USER_DEACTIVATE, CABINET_PARAM_CHANGE (operator-side); TENANT_CREATE, TENANT_PROVISION_RETRY, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_IDENTITY_EDIT, COMPLIANCE_DOC_UPLOAD, BYO_DB_CREDENTIAL_EDIT (platform-admin-side).
7.7 Encryption and data minimisation¶
| Data | Storage approach |
|---|---|
| Passwords | Keycloak (bcrypt). Platform never sees them. |
| OTP codes, bypass codes | SHA-256 hash only. |
| Signed-link tokens | SHA-256 hash only. Token in URL is the cleartext; never stored in DB. |
| Challenge values (DOB/RCCM) | Not stored by AUTH. challenge_hmac on signed_links = HMAC-SHA256(secret, normalizedValue\|\|fileId) computed by caller at issuance. AUTH never holds the raw value. |
| Operator MFA phone | Stored plaintext in V1 (E.164). Application-level encryption is a V2 hardening candidate; schema supports it without rename. |
| Customer auth attempt values | HMAC-SHA256(challengeSecret, submitted\|\|linkId) - never cleartext. |
7.8 Audit events¶
Every event in PRD §B.3 is emitted via ApplicationEventPublisher + @TransactionalEventListener(AFTER_COMMIT) into the spring-modulith-events-jdbc outbox (D4 + D7). Audit failure MUST NOT roll back the business action.
Key events grouped by subsystem:
| Subsystem | Selected events |
|---|---|
| Operator login | operator_login_attempt, operator_session_started, operator_remember_device_issued, operator_session_force_killed |
| MFA | operator_mfa_bypass_issued, operator_mfa_bypass_used, operator_step_up_required, operator_step_up_outcome |
| User lifecycle | operator_invited, operator_activated, operator_deactivated, operator_role_changed |
| Signed-link | signed_link_issued, signed_link_clicked, signed_link_permanently_revoked, signed_link_reissued |
| Customer challenge | customer_auth_success, customer_auth_failure, customer_auth_lockout_started |
| Tenant lifecycle | tenant_suspended_handled, tenant_reactivated_handled, tenant_auth_purged, initial_cabinet_admin_provisioned |
| Cross-tenant | cross_tenant_access_denied, platform_admin_step_up_required, platform_admin_step_up_outcome |
| DOB/RCCM reveal | operator_dob_rccm_reveal |
8. Performance & Bandwidth Budget¶
8.1 Signed-link verification (< 50 ms p95)¶
Hot path for every customer click:
1. SHA-256(token) - CPU only (~10 µs)
2. SELECT on UNIQUE (token_hash) index - single B-tree lookup (< 2 ms on warm connection)
3. Redis GET tenant-status - cache hit ≈ 1 ms; cache miss adds one platform DB SELECT + Redis SET ≈ 10 ms
4. Optimistic UPDATE signed_links (version++ + last_clicked_at) - single row write ≈ 2 ms
Total: < 10 ms on warm cache; < 50 ms on cold (AUTH-NFR-11 ✅).
8.2 Tenant-status cache sizing¶
Key: auth:tenant-status:{tenantId} Value: 8-byte string TTL: 30 s
Estimate for 500 tenants: 500 × ~50 bytes = 25 KB - negligible
Expected warm-hit rate: > 99% (lifecycle events are rare vs. request volume)
8.3 JWT validation¶
Keycloak JWKS public keys cached in-process (Spring Security OIDC resource server - automatic). No network call on hot path. Key rotation triggers a background JWKS refresh (standard Spring Security behaviour).
8.4 Keycloak Direct Grant latency¶
The login endpoint calls Keycloak's token endpoint synchronously. Target SLA: < 300 ms p95 (Keycloak is co-located in Docker Compose / Coolify network). If Keycloak is unavailable: return 503; do not fall back to skip auth.
9. Migration Strategy¶
All AUTH tables migrate under db/migration/platform/ (D9). Version numbers must follow the highest existing TENANT migration:
db/migration/platform/
├── V{N+0}__auth_users.sql
├── V{N+1}__auth_user_invitations.sql
├── V{N+2}__auth_remember_device_cookies.sql
├── V{N+3}__auth_mfa_bypass_codes.sql
├── V{N+4}__auth_signed_links.sql
├── V{N+5}__auth_customer_auth_attempts.sql
└── V{N+6}__auth_indexes.sql
Key DDL constraints:
-- users: CHECK ensures only PLATFORM_ADMIN may have null tenant_id
ALTER TABLE users ADD CONSTRAINT ck_users_tenant_role
CHECK (role = 'PLATFORM_ADMIN' OR tenant_id IS NOT NULL);
-- users: partial unique - allows PLATFORM_ADMIN rows (tenant_id=NULL) co-existing
CREATE UNIQUE INDEX idx_users_tenant_username
ON users (tenant_id, username) WHERE tenant_id IS NOT NULL;
-- signed_links: unique token hash for hot-path lookup
CREATE UNIQUE INDEX idx_signed_links_token_hash ON signed_links (token_hash);
-- signed_links: partial index for active links per tenant (suspension cascade)
CREATE INDEX idx_signed_links_tenant_active
ON signed_links (tenant_id)
WHERE state NOT IN ('PERMANENTLY_REVOKED','AUTO_REVOKED_PAID','AUTO_REVOKED_REISSUED','EXPIRED');
Reversibility: V1 migrations are forward-only (no Flyway UNDO). Schema additions for V2 (e.g., users.keycloak_realm_id for per-tenant realm silo) will be additive + nullable.
AUTH-TV-02: Audit event rows are retained indefinitely in V1 (no purge job). Schema must support a future purge: customer_auth_attempts.created_at indexed for range deletes; audit_entry.created_at (AUDIT module) same. No hard coupling to AUTH rows - AUTH data is referenced only by UUID in the audit log.
10. Integration Points¶
10.1 Keycloak (Admin API + Token endpoint)¶
All calls go through the KeycloakAdminClient interface (injectable mock in tests):
public interface KeycloakAdminClient {
TokenResponse directGrant(String username, String password);
String createUser(CreateUserRequest req); // returns keycloakUserId
void assignRealmRole(String keycloakUserId, String role);
void setUserEnabled(String keycloakUserId, boolean enabled);
void logoutUser(String keycloakUserId); // best-effort async
void setUserAttribute(String keycloakUserId, String key, String value);
}
| Operation | Endpoint | Called from |
|---|---|---|
| Validate password | POST /realms/{realm}/protocol/openid-connect/token |
Login handler |
| Create user | POST /admin/realms/{realm}/users |
Invite + TenantOnboarded handler |
| Assign role | POST /admin/realms/{realm}/users/{id}/role-mappings/realm |
Invite + TenantOnboarded |
| Enable/disable | PUT /admin/realms/{realm}/users/{id} {enabled:bool} |
Deactivation; suspend/reactivate |
| Revoke sessions | POST /admin/realms/{realm}/users/{id}/logout |
Deactivation; suspension cascade |
| JWKS | GET /realms/{realm}/protocol/openid-connect/certs |
JWT filter (cached) |
Resilience on suspension cascade: Keycloak session-kill calls are async, best-effort. If Keycloak is down, the Redis tenant-status cache (SUSPENDED) is already set - new tokens are rejected. Session kill is belt-and-suspenders. Failed calls are retried via the outbox (max 5 attempts, exponential back-off, 1 s / 2 s / 4 s / 8 s / 16 s).
10.2 NOTIF module¶
AUTH calls NOTIF via Spring module boundary (NotifService.dispatch(NotifRequest)):
| Use case | Channel | Template |
|---|---|---|
| Operator MFA OTP | SMS (+ WhatsApp fallback) | operator.mfa.otp |
| Operator invitation | email / SMS / WhatsApp | operator.invitation.link |
| Cabinet-admin bootstrap invitation | email only (V1) | operator.invitation.link |
| MFA bypass code to admin | SMS | operator.mfa.bypass_code |
Feature flags ([LEGAL-REVIEW] BLOQUANT per CLAUDE.md):
- feature.whatsapp.operator-mfa.enabled=false - until ARTCI cross-border transfer authorization (AUTH-OQ-04)
- feature.sms.operator-mfa.enabled=true - domestic SMS assumed; confirm with lawyer (AUTH-OQ-06)
10.3 TENANT module¶
- Events consumed (§1.1 above): via Spring Modulith outbox
@ApplicationModuleListener. - Synchronous queries:
TenantConfigService.resolveAgency(tenantId, agencyId)andTenantConfigService.getTenantBrandingContext(tenantId, agencyId). - AUTH exposes to TENANT:
AuthProvisioningService.retriggerCabinetAdminInvitation(tenantId)- called from TENANT detail screen when original NOTIF dispatch failed.
10.4 PAY module¶
AUTH registers a listener for AllContractsPaid:
@ApplicationModuleListener
void on(AllContractsPaid e) {
signedLinkService.autoRevoke(e.linkId(), RevocationReason.ALL_PAID);
// Emits signed_link_auto_revoked_paid to AUDIT via outbox
}
10.5 CP module¶
CP backend controllers handle /api/portail/{token}/... and call AUTH's services via Spring module boundary (in-process). AUTH exposes:
// Public API of the identity module (Spring Modulith module boundary)
public interface SignedLinkService {
LinkVerificationResult verifyLink(String token, String channel, InetAddress ip, String userAgent);
void issueLinks(List<LinkIssuanceRequest> requests); // called by OP/CAMP
void reissue(UUID fileId, UUID tenantId, UUID operatorUserId);
}
public interface ChallengeVerificationService {
ChallengeResult submitChallenge(String token, String submittedValue, InetAddress ip);
}
public interface CustomerSessionService {
CustomerSessionStatus getStatus(String token, String sessionToken);
}
10.6 ING module¶
ING validates that every customer row has the required challenge value at ingestion time (blocking anomaly MISSING_DOB_FOR_INDIVIDUAL / MISSING_RCCM_FOR_LEGAL_ENTITY). ING computes the challenge_hmac and passes it to SignedLinkService.issueLinks(...) at issuance time. AUTH stores it; AUTH never computes it.
11. Vertical Slice Decomposition¶
PO input - not for DEVELOPER. The DEVELOPER reads only the story file at
docs/stories/identity/<STORY-ID>.md. This section exists for the PRODUCT_OWNER to decompose into self-contained stories.
Tier overview¶
S0 [Tier 0, SERIAL] ─── must merge before anything else
└── S1 [Tier 1] ─── S2 [Tier 1] ─── S3 [Tier 1] ─── S4 [Tier 1] (parallelizable after S0)
└── S5 [Tier 2] (depends S4)
└── S6 [Tier 2] (depends S1 + S4) ⚠️ split candidate
└── S7 [Tier 2] (depends S1 + S6)
S0 - Auth module skeleton + platform schema + Keycloak realm config [V1]¶
- Tier: 0 (SERIAL)
- Complexity: M
- Scope: Spring Modulith module stub
com.altarys.papillon.pcs.controlplane.identity; 6 platform DB Flyway migrations (users, user_invitations, remember_device_cookies, mfa_bypass_codes, signed_links, customer_auth_attempts) with all constraints + partial indexes; Keycloak realmpapillon-platformimported via JSON (5 roles, access-token TTL=5 min, session TTL=8h, custom claim mappers); JWT validation filter skeleton (parses + signature-validates, no tenant logic); Redis tenant-status cache interface; Testcontainers Keycloak configuration; smoke health check. - Files:
IdentityModule.java,JwtValidationFilter.java(skeleton),TenantStatusCache.java(interface + Redis impl stub),db/migration/platform/V{N}__auth_*.sql(×6),infra/keycloak/papillon-platform-realm.json,docker-compose.yml(Keycloak service addition) - ACs: (1) All 6 migrations apply cleanly on platform DB; (2) Keycloak realm starts with 5 roles + custom mappers; (3)
GET /actuator/health→ 200; (4) A JWT from the test realm is accepted by the filter and a claim set is extracted without error. - Dependencies: TENANT S0 (Postgres × 2, Redis, docker-compose harness)
S1 - Operator login: password + MFA + remember-device [V1] ⚠️ Split candidate¶
- Tier: 1 (parallelizable after S0)
- Complexity: L
- Scope: Keycloak Direct Grant integration (
KeycloakAdminClientimpl);POST /api/auth/operator/login; Redis OTP session stash; NOTIF integration for SMS OTP dispatch;POST /api/auth/operator/mfa/verify(OTP verify + remember-device cookie issuance);POST /api/auth/operator/mfa/resend(SMS attempt 2 + WhatsApp fallback routing via feature flag);POST /api/auth/operator/logout;GET /api/auth/operator/me; full JWT filter chain (tenant-status cache, TenantContext build); operator console login page (React: LoginPage + MfaPage). - Suggested sub-slices:
- S1a: Password-only login → JWT (remember-device cookie present path) - happy path + bad credentials + locked/deactivated account states
- S1b: MFA flow (SMS OTP) + remember-device cookie issuance - happy path + OTP timeout + 3 OTP failures → MFA lock
- S1c: WhatsApp OTP fallback + MFA bypass code issuance/use/expiry - feature-flagged
- ACs: (1) Valid password + no remember-device → OTP sent +
mfaRequired:true; (2) Correct OTP +rememberDevice:true→ JWT +papillon_devicecookie; (3) Valid remember-device cookie → MFA skipped; (4) Bad password → 401 generic; (5) 3 wrong OTPs → MFA locked 15 min. - Feature flags:
feature.whatsapp.operator-mfa.enabled(defaultfalse) [LEGAL-REVIEW] - Audit:
operator_login_attempt,operator_session_started,operator_remember_device_issued,operator_session_explicit_logout
S2 - Operator session lifecycle + step-up MFA [V1]¶
- Tier: 1 (parallelizable after S0)
- Complexity: M
- Scope: Idle session timeout (30 min server-side, checked on every API call against last-activity timestamp); 1-min warning SSE/polling channel; absolute 8h lifetime;
POST /api/auth/operator/step-up/initiate;POST /api/auth/operator/step-up/verify;POST /api/auth/operator/users/{userId}/mfa-bypass(CABINET_ADMIN only); session-timeout warning banner (React); step-up OTP modal (React). - ACs: (1) 30-min idle → next API call returns 401; (2) 1-min warning banner before idle kick; (3) 8-hour absolute limit → re-login required; (4) Sensitive action
USER_DEACTIVATE→ step-up OTP required before proceeding; (5) MFA bypass code delivered to CABINET_ADMIN's number (not operator's). - Audit:
operator_session_idle_timeout,operator_session_absolute_timeout,operator_step_up_required,operator_step_up_outcome,operator_mfa_bypass_issued,operator_mfa_bypass_used
S3 - Operator user management: invite + activate + role/scope + deactivate [V1] ⚠️ Split candidate¶
- Tier: 1 (parallelizable after S0)
- Complexity: L
- Scope: User invite (all 3 channels via NOTIF); 7-day invitation activation link flow;
GET/POST /api/auth/operator/users;GET/PUT /api/auth/operator/users/{userId};POST .../deactivate(Keycloak session kill + remember-device revoke within 60s SLA; step-up USER_DEACTIVATE);POST .../reinvite; password-reset self-service; BYO-DB agency scope validation viaTenantConfigService; user list + user detail + invite form (React). - Suggested sub-slices:
- S3a: Invite + activate (NOTIF dispatch, activation link click, password + MFA phone setup)
- S3b: Role/scope edit (step-up USER_ROLE_CHANGE, BYO-DB agency validation) + deactivate cascade
- ACs: (1) Cabinet admin invites by email → activation link → account ACTIVE; (2) Invitation expires after 7 days →
INVITATION_EXPIRED; (3) Role change → tokens valid until natural expiry (≤ 15 min); (4) Deactivation → Keycloak sessions killed within 60 s; (5) Cabinet admin cannot deactivate themselves; (6) Agency UUID not found in BYO-DB → 422 InvalidAgencyScope. - Audit:
operator_invited,operator_activated,operator_invitation_expired,operator_role_changed,operator_deactivated,operator_password_reset_initiated,operator_password_reset_completed
S4 - Signed-link issuance + click verification [V1]¶
- Tier: 1 (parallelizable after S0)
- Complexity: M
- Scope: Token generation (
SecureRandom32-byte → Base64url; SHA-256 hash stored);POST /api/internal/auth/links(bulk issuance, validates R-CP-018 - blocks missing challenge_hmac / missing DOB/RCCM flag); link click verification (token hash lookup, TTL, revocation, tenant-frozen check, branding context assembly via TenantConfigService);POST /api/auth/operator/files/{fileId}/links/reissue;AllContractsPaidhandler (auto-revoke); link state machine (ISSUED, CLICKED, EXPIRED, AUTO_REVOKED_REISSUED, AUTO_REVOKED_PAID);SignedLinkServiceSpring API exposed to CP module. - ACs: (1) Bulk issuance of 100 links completes in < 2 s; (2) File with missing
challenge_hmac→ blocked withMISSING_CHALLENGE_HMACreason; (3) Click on valid link → branding context +VALIDstatus returned; (4) Click on expired link →EXPIRED+ French copy; (5)AllContractsPaid→ link auto-revoked; (6) Operator re-issue → previous link revoked + new link with fresh counters (0/5, 0/3, fresh 72h TTL). - Audit:
signed_link_issued,signed_link_clicked,signed_link_clicked_expired,signed_link_clicked_revoked,signed_link_reissued,signed_link_auto_revoked_paid
S5 - Customer challenge + lockout cycle [V1]¶
- Tier: 2 (depends S4)
- Complexity: M
- Scope: Challenge submission (DOB normalization
JJ/MM/AAAA→ ISO-8601; RCCM uppercase + strip whitespace; HMAC comparison againstsigned_links.challenge_hmac; constant-time); optimistic concurrency (versioncolumn, retry on conflict); 5-attempt lockout; 30-min lockout window; 3-cycle permanent revoke + anomaly event emission to OP; customer server-side session management (link-token bound);ChallengeVerificationServiceandCustomerSessionServiceexposed to CP;POST /api/auth/operator/files/{fileId}/dob-rccm/reveal(audited, no step-up required - reveal is operational, not mutating). - ACs: (1) Correct DOB →
AUTHENTICATED+ session token; (2) Wrong DOB → counter incremented + "Tentative X/5"; (3) 5th wrong → 30-min lockout +auth.customer.lockout; (4) After lockout expiry → attempts reset to 0/5, cycle incremented; (5) After 3 lockout cycles →PERMANENTLY_REVOKED+ OP anomaly event +AUTH_PERMANENTLY_LOCKED; (6) Concurrent multi-device submissions → exactly one result applied (no double-increment via optimistic retry). - Audit:
customer_auth_success,customer_auth_failure,customer_auth_lockout_started,customer_auth_lockout_resumed,signed_link_permanently_revoked,operator_dob_rccm_reveal
S6 - Tenant-lifecycle event handlers + tenant-status guard [V1] ⚠️ Split candidate¶
- Tier: 2 (depends S1 + S4)
- Complexity: L
- Scope: Full tenant-status Redis cache (wired into JWT filter from S1);
TenantOnboardedhandler (bootstrap CABINET_ADMIN + Keycloak user + invitation);TenantSuspendedcascade (Redis invalidate first → users.tenant_frozen → remember-device revoke → signed_links.tenant_frozen → Keycloak session kill async);TenantReactivatedcascade (restore);TenantSoftDeleted(anonymize users + hard-delete auth rows - idempotent);AgencyDisabled(audit ack only - R-OP-018);TenantSettingsChanged(cache invalidate onmin_auth_level);POST /api/control/auth/tenants/{tenantId}/cabinet-admin/reinvite. - Suggested sub-slices:
- S6a: Tenant-status cache + guard (wire into S1's JWT filter; return correct HTTP status per R-OP-017)
- S6b:
TenantSuspended+TenantReactivatedcascade (30-s SLA) - S6c:
TenantSoftDeletedanonymize + hard-delete (5-min SLA; idempotent) - S6d:
TenantOnboardedbootstrap (Keycloak create + invitation dispatch; idempotency guard) - ACs (sample): (1)
TenantSuspended→ new operator login returns 503 within 30 s; (2) Customer link click on frozen tenant → HTTP 503 + generic copy (no leak of suspension reason); (3)TenantReactivated→ tenant accessible within 30 s; (4)TenantOnboarded→ CABINET_ADMIN created + invitation sent; (5) Re-delivered event → idempotent skip logged. - Audit:
tenant_suspended_handled,tenant_reactivated_handled,tenant_auth_purged,initial_cabinet_admin_provisioned,agency_disabled_acknowledged
S7 - Platform-admin auth + cross-tenant step-up [V1]¶
- Tier: 2 (depends S1 + S6)
- Complexity: S
- Scope:
TenantContext.platformAdmin()sentinel in JWT filter (detectPLATFORM_ADMINrole + nulltenant_idclaim); bypassWHERE tenant_id = ?for platform-admin repository queries + emitPLATFORM_ADMIN_QUERYaudit; rejectPLATFORM_ADMINon/api/tenant/me/...with 403; extend step-up enforcement to platform-admin action list (R-OP-010 platform-admin side); last-activePLATFORM_ADMINdeactivation guard (R-ADM-008);cross_tenant_access_deniedevent for non-PLATFORM_ADMINcross-tenant attempts. - ACs: (1)
PLATFORM_ADMINJWT accepted on/api/control/tenants/...; (2)PLATFORM_ADMINJWT rejected (403) on/api/tenant/me/...; (3) Cross-tenant user list query producesPLATFORM_ADMIN_QUERYaudit entry per result; (4) Non-PLATFORM_ADMINcross-tenant attempt →cross_tenant_access_denied+ 403; (5)TENANT_SUSPENDaction requires step-up regardless of remember-device. - Audit:
platform_admin_step_up_required,platform_admin_step_up_outcome,cross_tenant_access_denied
12. Technical Risks¶
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | Keycloak Direct Grant deprecated in future Keycloak version | Low | High | Isolated behind KeycloakAdminClient interface. V2 migration to auth-code + PKCE is an interface swap; frontend REST API unchanged. |
| R2 | Suspension cascade > 30 s (many users + Keycloak rate-limit) | Medium | High | Cache invalidation is step 1 (immediate guard). Keycloak calls are async. SLA measured on cache + DB portion only. Keycloak kill is belt-and-suspenders. |
| R3 | Concurrent multi-device challenge submissions → split-brain on attempt_counter | Low | Medium | Optimistic concurrency (version column); conflict → retry with fresh read. Under V1 load (≤ 2 devices per link), conflict rate < 1%. |
| R4 | Redis outage → all tenant-status reads fail | Medium | High | On cache miss, fall back to platform DB read (TTL 30 s means cache miss is rare). Redis is a performance accelerator, not a hard auth dependency. |
| R5 | Keycloak unavailable during suspension cascade | Low | Medium | Redis cache updated first (immediate guard). Keycloak kill retried via outbox (5 attempts, exponential back-off). |
| R6 | Signed-link token collision | Negligible | High | 256-bit SHA-256 on 256-bit random - preimage infeasible. UNIQUE on token_hash provides DB-level integrity guarantee. |
| R7 | TenantOnboarded redelivery → duplicate CABINET_ADMIN |
Low | High | Idempotency check on first line: SELECT WHERE tenant_id=? AND role='CABINET_ADMIN'. On hit → emit idempotent_skip=true, exit. |
| R8 | SMS OTP not received + WhatsApp blocked by ARTCI | Medium | Medium | Feature flag gates WhatsApp. Fallback is "contact admin for bypass code" (R-OP-004). Document in operator runbook. |
| R9 | BYO-DB agency scope becomes stale (agency deactivated after user creation) | Low | Low | TV-07 documented: no re-validation on token refresh. Downstream modules gate new actions on disabled agencies. OPERATOR cannot act on disabled agency regardless. |
| R10 | First-platform-admin bootstrap race on cold-start | Low | Medium | OPS runbook (AUTH-OQ-09) provisions the first PLATFORM_ADMIN user via Keycloak admin CLI before any tenant is created. Not a product feature. |
13. Test Infrastructure¶
13.1 Testcontainers extensions (reuse TENANT S0 harness)¶
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:25.0")
.withRealmImportFile("/testcontainers/papillon-platform-realm-test.json");
// PostgreSQLContainer (platform DB) and GenericContainer (Redis) already in TENANT harness
13.2 Mock interfaces¶
@MockBean KeycloakAdminClient keycloakAdminClient; // in all tests
@MockBean NotifService notifService; // in all tests
@MockBean TenantConfigService tenantConfigService; // for cross-module calls
13.3 Sample test: tenant isolation¶
@Test
void signedLink_isNotAccessibleFromAnotherTenant() {
UUID tenantA = createActiveTenant();
UUID tenantB = createActiveTenant();
SignedLink link = signedLinkService.issueOne(request(tenantA, fileId));
// When: JWT for tenant B is in security context
withTenantContext(tenantB, () ->
assertThatThrownBy(() -> signedLinkService.verifyLink(link.token(), "sms", ip, ua))
.isInstanceOf(LinkNotFoundException.class)
);
}
13.4 Sample test: optimistic concurrency on attempt counter¶
@Test
void concurrentChallengeAttempts_doNotDoubleIncrementCounter() throws Exception {
// Given: link with attempt_counter=4, cycle_counter=0
SignedLink link = createLinkWithAttempts(4);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
AtomicInteger lockoutCount = new AtomicInteger(0);
for (int i = 0; i < 2; i++) {
pool.submit(() -> {
try {
challengeService.submit(link.token(), "wrong-dob", ip);
} catch (CustomerLockoutException e) {
lockoutCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await(5, SECONDS);
// Then: exactly one triggers lockout; counter is exactly 5
SignedLink refreshed = signedLinkRepository.findById(link.linkId()).orElseThrow();
assertThat(refreshed.attemptCounter()).isEqualTo(5);
assertThat(refreshed.state()).isEqualTo(LinkState.LOCKED);
assertThat(lockoutCount.get()).isEqualTo(1);
}
13.5 Sample test: tenant suspension propagation (< 30 s SLA)¶
@Test
void tenantSuspension_blocksOperatorLoginWithin30Seconds() {
UUID tenantId = createActiveTenant();
String jwt = loginOperator(tenantId); // valid JWT
eventPublisher.publishEvent(new TenantSuspended(tenantId, "FRAUD", Instant.now()));
await().atMost(30, SECONDS).untilAsserted(() ->
given().header("Authorization", "Bearer " + jwt)
.get("/api/auth/operator/me")
.then().statusCode(403)
);
}
13.6 Slow-3G smoke test (customer side - required before declaring any customer story DONE)¶
- Throttle: Chrome DevTools → Slow 3G (750 Kbps down, 250 Kbps up, 400 ms RTT)
- Assert: link landing page (including branding context) renders within 5 s
- Assert: challenge submission round-trip < 3 s
- Assert: total network payload (Network tab, gzipped) < 200 KB (AUTH-NFR-01)
14. Local Dev Environment¶
AUTH reuses TENANT S0's docker-compose.yml. The following addition is required:
# docker-compose.yml addition for AUTH
keycloak:
image: quay.io/keycloak/keycloak:25.0
command: start-dev --import-realm
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
volumes:
- ./infra/keycloak/papillon-platform-realm.json:/opt/keycloak/data/import/papillon-platform-realm.json
ports:
- "8180:8080"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready || exit 1"]
interval: 15s
timeout: 5s
retries: 10
depends_on:
postgres-platform:
condition: service_healthy
infra/keycloak/papillon-platform-realm.json includes:
- Realm: papillon-platform
- Realm roles: PLATFORM_ADMIN, CABINET_ADMIN, OPERATOR, SUPERVISOR, READ_ONLY
- Confidential client: papillon-backend (Direct Grant enabled; client secret: dev-secret-change-in-prod)
- Public client: papillon-frontend (PKCE; for V2 readiness)
- Access token TTL: 300 s; session absolute max: 28800 s
- Claim mappers: tenant_id, agency_scope (multivalued), country_code, papillon_role ← from user attributes
- Test user: [email protected] / Admin1234! / role PLATFORM_ADMIN (local dev only)
application-local.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/papillon-platform
papillon:
keycloak:
admin:
base-url: http://localhost:8180
realm: papillon-platform
client-id: papillon-backend
client-secret: ${KEYCLOAK_BACKEND_SECRET:dev-secret-change-in-prod}
auth:
signed-link:
challenge-hmac-secret: ${AUTH_CHALLENGE_HMAC_SECRET:dev-hmac-secret-32-chars-min!!}
remember-device:
cookie-signing-secret: ${AUTH_COOKIE_SIGNING_SECRET:dev-cookie-signing-secret-change!}
features:
whatsapp-operator-mfa-enabled: false
Smoke verification:
# 1. Start all services
docker-compose up -d
# 2. Wait for Keycloak
until curl -sf http://localhost:8180/health/ready; do echo "waiting for Keycloak..."; sleep 3; done
# 3. Start backend
./gradlew bootRun --args='--spring.profiles.active=local'
# 4. Get a test JWT via Direct Grant
TOKEN=$(curl -s -X POST \
http://localhost:8180/realms/papillon-platform/protocol/openid-connect/token \
-d 'grant_type=password&client_id=papillon-backend&client_secret=dev-secret-change-in-prod&[email protected]&password=Admin1234!' \
| jq -r '.access_token')
# 5. Call /me - expect 200 with role=PLATFORM_ADMIN, tenantId=null
curl -s http://localhost:8080/api/auth/operator/me \
-H "Authorization: Bearer $TOKEN" | jq .
Appendix A - ADRs¶
ADR-AUTH-01: All AUTH tables on platform DB (not BYO-DB)¶
Decision: signed_links and customer_auth_attempts remain on the platform DB alongside all other AUTH tables.
Rationale: (1) Hot-path verification (< 50 ms p95) requires a single DB read. (2) Authentication primitives are control-plane data, not tenant business data. (3) DPA positioning: auth data under platform custody. (4) V2 realm-per-tenant can be added as a config change without data migration.
Trade-off: In BYO-DB profile, a tenant's auth data and business data live in different DBs. This is a feature from a data-separation standpoint - the tenant's own DB never holds auth primitives.
ADR-AUTH-02: Signed-link token = 32-byte random + SHA-256 (not HMAC)¶
Decision: SecureRandom(32) → Base64url token; SHA-256(token) stored; lookup is hash equality (not constant-time MAC comparison).
Rationale: HMAC would add a server secret but provide no real benefit since revocation is DB-based anyway (state machine). SHA-256 on 256-bit random input is cryptographically sufficient. UNIQUE constraint on token_hash adds DB-level integrity.
ADR-AUTH-03: Attempt counters on Postgres (no Redis front in V1)¶
Decision: attempt_counter, cycle_counter, lockout_until on the signed_links row; optimistic locking via version column.
Rationale: V1 multi-device concurrency on a single link is rare (≤ 2 devices). Optimistic concurrency is simple and correct. Redis-fronted counters would add a failure mode. Can be added later without schema change if contention is measured.
ADR-AUTH-04: Keycloak Direct Grant (ROPC) for operator login¶
Decision: Backend calls POST /token grant_type=password (confidential client). Frontend never calls Keycloak directly.
Rationale: Full UX control (custom form, remember-device check before MFA step, step-up modals). ROPC deprecation in OAuth 2.1 targets public clients; backend-to-Keycloak with a confidential client is controlled. V2 migration to auth-code + PKCE changes only the backend-Keycloak interaction - frontend REST API unchanged.
Appendix B - Open Questions¶
| ID | Topic | Blocks V1 dev? | Resolution target |
|---|---|---|---|
| AUTH-OQ-01 | CIMA Reg. 01-24 acceptability of signed-link + DOB/RCCM for renewal consent + payment authorization | No (AUTH-TV-01) | Pre-production - qualified lawyer |
| AUTH-OQ-02 | Audit-log retention period for AUTH events | No (AUTH-TV-02: indefinite) | AUDIT module design |
| AUTH-OQ-03 | CIMA cybersecurity reg (010-24) obligations on sub-processor | No | Pre-production - lawyer + LEGAL_AUDITOR re-pass |
| AUTH-OQ-04 | Cross-border transfer: WhatsApp Cloud API (Meta infra) for operator MFA fallback | Feature-flagged [LEGAL-REVIEW] | Before enabling WhatsApp - ARTCI position |
| AUTH-OQ-05 | Mandatory information surface on customer landing page (Reg. 01-24 article-anchored list) | No | DESIGNER session |
| AUTH-OQ-06 | Lawful basis for operator MFA phone + customer DOB/RCCM processing | No | Pre-production - DPIA / lawyer |
| AUTH-OQ-09 | First-platform-admin bootstrap OPS runbook | No (not a product feature) | OPS, pre-production |
| OQ-X1 | AUTH consumes TenantOnboarded synchronously or asynchronously? | RESOLVED | Asynchronous (§1.2). Brief gap window documented. |