Story TENANT-004: Provisioning pipeline (shared-DB) + Keycloak Organization + TenantOnboarded¶
Module: tenant-admin Slice: S3a (Vertical Slice Decomposition §11, shared-DB subset) + S3c (Keycloak) Side: [ADMIN] + [BACKEND] Version target: [V0.0.0] Priority: 4 Depends on: TENANT-002, TENANT-003 Can develop concurrently with: TENANT-007 (BYO step), TENANT-008 (lifecycle) — once this merges Merge order: After TENANT-003 Estimated complexity: L PRD User Stories: TENANT-US-02 Wireframe: docs/wireframes/tenant-admin/TENANT-004/ (minimal provisioning UI; design ref ADM-003 onglet Provisionnement + ProvisioningPipeline component)
Objective¶
Turn a PENDING tenant into an ACTIVE one in a single, idempotent, recoverable step. The pipeline binds the country profile, creates the tenant's Keycloak Organization + client/roles, flips status to ACTIVE, and emits TenantOnboarded (consumed by AUTH to bootstrap the initial CABINET_ADMIN user, and by NOTIF/AUDIT). Step state is persisted so a retry re-runs only steps not yet DONE. V0.0.0 covers the shared-DB path only — the BYO-DB step and the default-agency step are added later (TENANT-007 / V0.0.4 and TENANT-005 / V0.0.2 respectively). A minimal admin UI shows the live 5-step stepper with per-step retry.
Backend Scope¶
Entities¶
tenant_provisioning_run (platform DB): id UUID PK · tenant_id UUID FK · step TEXT (CP_BIND|BYO_DB|KEYCLOAK|DEFAULT_AGENCY|EVENT_EMIT) · status TEXT (PENDING|RUNNING|DONE|FAILED) · last_error TEXT NULL · attempt_count INT · started_at TIMESTAMPTZ NULL · completed_at TIMESTAMPTZ NULL.
Migrations¶
db/migration/platform/V3__tenant_provisioning_run.sql— PK(id); IDX(tenant_id, step); UNIQUE(tenant_id, step) (exactly one row per (tenant,step) for idempotency).
Service Layer¶
TenantOnboardingService.provision(tenantId)— idempotent orchestrator (TR-002). Idempotency key =tenant.id. Upserts run rows {CP_BIND, BYO_DB, KEYCLOAK, DEFAULT_AGENCY, EVENT_EMIT}=PENDING, then executes in order, skipping steps already DONE:- CP_BIND:
CountryProfileService.lookup(country_code); on not-found → FAILEDlast_error=CP_NOT_SEEDED, tenant stays PENDING. - BYO_DB: in V0.0.0 this step is a no-op DONE (skipped — shared DB). The real BYO branch is added in TENANT-007.
- KEYCLOAK: via
KeycloakProvisioningPort(thin interface, mockable). Idempotent:existsByClientId/org-exists check first; else create the tenant's Keycloak Organization (sharedpapillonrealm, prd-v0 §5.8) + client + default roles {Admin, Operator, Viewer}; set org attributestenant_id,country_code,group_id(null). On unreachable → FAILEDlast_error=KEYCLOAK_UNREACHABLE. - DEFAULT_AGENCY: in V0.0.0 this step is a no-op DONE (the
agencytable does not exist until V0.0.2). TENANT-005 turns this into a real "create PRINCIPAL agency" step (AC-02.1). - EVENT_EMIT: flip
tenant.status=ACTIVE, setactivated_at=now(), publishTenantOnboarded(AFTER_COMMIT). Re-runs MUST NOT re-emit. - V0 activation gate: before flipping to ACTIVE, assert
tenant_settings.notif_sms_sender_idANDnotif_wa_business_idare non-null (R-TEN-NOTIF-01). If missing → FAILED with a French message; do not activate. (The V1 DPA-present gate is deferred to V1.) KeycloakProvisioningPortinterface + a real impl (keycloak-admin-client) and a test mock.TenantOnboardedrecord incom.altarys.papillon.pcs.controlplane.tenant.events:{tenantId, countryCode, plan, primaryContact, defaultAgencyId(nullable in V0.0.0), byoDb, occurredAt}. Dispatched viaApplicationEventPublisher; persisted inspring-modulith-events-jdbcoutbox so a worker re-delivers on failure.
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/control/tenants/{id}/provision |
header Idempotency-Key: {tenantId} |
200 {data:{status:"ACTIVE"}} · 422 {step, error} on step failure |
papillon-platform-admin |
| GET | /api/control/tenants/{id} |
— | 200 detail incl. status, lastProvisioningError, per-step run states |
papillon-platform-admin |
| GET | /api/control/tenants?status=&cursor=&limit= |
— | 200 minimal list (id, legalName, status) for the V0 admin screen; writes PLATFORM_ADMIN_QUERY audit |
papillon-platform-admin |
The list endpoint is intentionally minimal in V0 (just enough to find a tenant and trigger provision/retry). The rich list (status tabs, search, DPA banner) and full detail tabs are V1.
Validation Rules¶
- Provision allowed only when
tenant.status = PENDING. Idempotency-Keyrequired on POST /provision; must equal the path tenant id.
Multi-Tenant Considerations¶
tenant_provisioning_run lives on the platform DB (read by the orchestrator before tenant routing is valid). The list endpoint is the only cross-tenant read in V0 TENANT — it runs through PlatformAdminTenantRepository under the papillon-platform-admin role and writes a PLATFORM_ADMIN_QUERY audit entry. Keycloak uses one shared realm with one Organization per tenant (prd-v0 §5.8); the tenant_id claim is emitted by a protocol mapper.
Audit & Logging¶
TENANT_PROVISIONEDon successful PENDING→ACTIVE.PLATFORM_ADMIN_QUERYon the cross-tenant list.- Audit best-effort-immediate via outbox (NFR-09). Prometheus-style alert when a tenant stays PENDING > 24 h (Technical Risk R3) — note for ops; instrumentation hooks land with OP-020 observability.
Frontend Scope¶
Pages & Routes¶
/admin/tenants— minimalTenantListPage(table: raison sociale, statut badge, action)./admin/tenants/:id— minimal detail showing theProvisioningPipelinecomponent (live 5-step stepper) + a « Relancer » button. (Full tabbed detail page is V1.)
Components¶
- New module-specific
ProvisioningPipelinecomponent (design §8.8): renders the 5 steps with per-step status icon, duration, and a retry button on the failed step. - Reuse
Badge(status mapping),Button,InlineBanner,Skeleton.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Stepper polls; current step shows spinner | « ⏳ {n}. {label} — En cours… » |
| Empty | No run yet for a freshly created PENDING tenant | « Aucun provisionnement lancé. » + [Lancer le provisionnement] |
| Error | Failed step shows French error code + retry button; ✅ steps not re-run | KEYCLOAK: « Service d'authentification indisponible. Réessayez dans quelques minutes. » · CP_NOT_SEEDED: « Profil pays CI introuvable. Contactez l'équipe Papillon. » · retry: « Relancer à partir de l'étape {n} » · skipped: « ⊘ {n}. {label} (ignoré — déjà complété) » |
| Offline / low network | Pipeline keeps last known state; « Annuler » (ghost) does not undo completed (idempotent) steps; no UI timeout before 60 s (NFR-03) | « Connexion lente — état non actualisé. » |
| Success | All steps ✅ → toast → tenant ACTIVE | « Cabinet {legal_name} activé. » |
Responsive Behavior¶
Desktop-first; stepper stacks vertically on ≤ 768 px. Platform-admin small-screen banner on 360–767.
Connectivity Behavior (CUSTOMER stories only)¶
N/A — admin-side. Provisioning is resilient to the admin's network dropping between POSTs because the step state is server-persisted and the call is idempotent (see Test 13.2.3 in arch).
Interactions¶
- « Relancer » re-issues POST /provision with the same
Idempotency-Key; only PENDING/FAILED steps re-run.
Acceptance Criteria¶
AC-02.2: Idempotent provisioning + retry
Given provisioning failed (e.g. Keycloak realm timeout)
When the platform admin clicks "Relancer le provisionnement"
Then only steps not yet completed re-run; previously-completed steps are detected and skipped
And tenant status stays PENDING with a populated last_provisioning_error until success
AC-02.3: TenantOnboarded emitted exactly once
Given a tenant transitions PENDING → ACTIVE
Then exactly one TenantOnboarded is published carrying {tenantId, countryCode, plan, primaryContact, defaultAgencyId(nullable in V0.0.0), byoDb}
And re-runs on the same tenant do NOT re-emit it
AC-02.1 (default-agency auto-creation) is deferred to TENANT-005 / V0.0.2, because the
agencytable is introduced there. In V0.0.0 the DEFAULT_AGENCY step is a recorded no-op anddefaultAgencyIdis null in the event.
Compliance Rules¶
- No new
INCERTAIN/PRÉLIMINAIREobligation raised. The CIMA/FNE assumptions captured at create (TENANT-003) carry forward. The V0 activation gate uses NOTIF sender IDs (R-TEN-NOTIF-01), not the V1 DPA gate.
Standards & Conventions¶
docs/standards/api-guidelines.md— idempotency, envelope, cursor pagination, 422 step failure.docs/standards/java-spring-guidelines.md— domain events past-tense noEventsuffix in…tenant.events; Spring Modulith outbox.docs/standards/multi-tenant-model.md— Keycloak shared-realm + Organizations (prd-v0 §5.8).docs/standards/multi-datasource-patterns.md— provisioning is the only place that knows thebyo_dbflag (single chokepoint).docs/standards/design-system.md— ProvisioningPipeline, Badge, Button.docs/standards/critical-rules.md— audit on mutation; tenant isolation.
Testing Requirements¶
Unit Tests¶
- Orchestrator skips DONE steps on retry; FAILED step re-runs and increments
attempt_count. - Activation gate blocks ACTIVE when notif sender IDs missing.
Integration Tests¶
- Idempotent retry (arch §13.2.2): force KEYCLOAK failure, assert CP_BIND stays DONE/attempt=1 while KEYCLOAK retries; on recovery → ACTIVE.
- Network-drop analogue (arch §13.2.3): processed-but-response-dropped, retry with same key → 5 steps DONE, exactly one
TenantOnboardedrecorded. TenantOnboardedpublished once; outbox row persisted.PLATFORM_ADMIN_QUERYaudit written on list; 403 (not 404) on cross-role.- Keycloak step idempotent (existsByClientId/org-exists short-circuits).
What QA Will Validate¶
- Smoke per arch §14.3 (create → provision → status ACTIVE).
- Stepper renders all 5 states; retry only re-runs failed steps; French copy matches.
Out of Scope¶
- BYO-DB provisioning step + Flyway-on-BYO + credential encryption (TENANT-007 / V0.0.4).
- Default PRINCIPAL agency creation (TENANT-005 / V0.0.2 — turns DEFAULT_AGENCY into a real step + AC-02.1).
- Rich tenant list (tabs/search/DPA banner) and full tabbed detail page (V1).
- Suspend/reactivate/soft-delete (TENANT-008 / V0.1.0).
- Initial CABINET_ADMIN user creation itself (owned by AUTH, consuming TenantOnboarded).
Definition of Done¶
- [ ]
POST /provisionidempotent withIdempotency-Key=tenantId; per-step state intenant_provisioning_run - [ ] Steps: CP_BIND real; BYO_DB + DEFAULT_AGENCY recorded no-op DONE in V0.0.0; KEYCLOAK creates Organization+client+roles (idempotent, mockable port); EVENT_EMIT flips ACTIVE + publishes TenantOnboarded once
- [ ] V0 activation gate (notif sender IDs present) enforced
- [ ] Minimal list + provisioning detail UI renders at 360/768/1024; all 5 stepper states
- [ ] French micro-copy matches design spec exactly
- [ ] AC-02.2 + AC-02.3 pass manually
- [ ]
TENANT_PROVISIONED+PLATFORM_ADMIN_QUERYaudit entries present; 403 (not 404) on cross-role - [ ] Idempotency + network-drop tests pass; exactly-once TenantOnboarded
- [ ] Unit + integration tests passing; no silent failure on Keycloak/DB unavailability