Story CP-001: TenantContext + Auth Foundation¶
Module: control-plane Slice: Slice 1 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 1 Depends on: None Estimated complexity: L
Objective¶
Establish the foundational multi-tenant infrastructure that every other slice depends on.
This slice implements the TenantContext propagation mechanism (using Java 25 ScopedValue), the Spring Security filter
chain for JWT validation with Keycloak, and the dual DataSource configuration that routes queries to either the platform
database or the tenant database. Without this slice, no tenant-aware code can exist.
Backend Scope¶
Entities & Records¶
No domain entities in this slice. This is pure infrastructure.
Records/Classes to create:
// TenantContext — immutable value object carried via ScopedValue
public record TenantContext(
UUID tenantId,
String countryCode,
String dbProfile // "SHARED" | "SCHEMA" | "DB_PER_TENANT" | "BYO_DB"
) {}
Infrastructure Layer¶
TenantContext propagation:
- TenantContextHolder.java — Static ScopedValue
Provides current() accessor. Falls back to ThreadLocal adapter for contexts where ScopedValue doesn't propagate (e.g., @Async).
Security filters (ordered):
1. TenantResolutionFilter.java — Spring Security filter. Extracts tenant_id, country_code from JWT claims. Resolves DB profile from platform DB. Sets ScopedValueJwtTenantExtractor.java — Utility to extract tenant claims from Keycloak JWT. JWT claims expected: user_id (UUID), tenant_id (UUID), country_code (ISO), roles[] (role names), iat, exp.
3. TenantMdcFilter.java — Sets tenant_id and user_id in SLF4J MDC for structured logging.
DataSource configuration:
- DataSourceConfig.java — Configures two named DataSources:
- platformDataSource — connects to the platform database (tenants, users, billing, country config)
- tenantDataSource — connects to the tenant database (employees, org units, audit entries, module-specific data)
- MVP: both point to the same PostgreSQL instance, different schemas or same schema based on profile
- TenantDataSourceRouter.java — For Profile C (DB-per-tenant): dynamically resolves the tenant's database connection from tenant.db_connection_url in the platform DB. Uses a connection pool cache (HikariCP) with eviction.
Security configuration:
- SecurityConfig.java — Spring Security 6.x configuration:
- Stateless session (no cookies)
- JWT resource server with Keycloak public key validation
- Public endpoints: /api/v1/onboarding/**, /api/v1/auth/login, /actuator/health
- All other endpoints require valid JWT
- CORS configured for Papillon frontend + Admin console origins
Application configuration:
- application.yml — Keycloak issuer URI, JWT validation, dual DataSource connection properties, profile-specific overrides
Repository Layer¶
TenantAwareRepository.java— Abstract base class/interface pattern. All tenant-scoped repositories MUST extend this. Automatically injectstenant_idfrom TenantContext into every query. Uses Spring Data JDBC@Querywith:tenantIdparameter binding.
Validation Rules¶
- JWT must be present on all non-public endpoints (401 if missing)
- JWT
tenant_idclaim must reference an existing tenant in platform DB (403 if not found) - JWT signature validated against Keycloak public key (cached locally, refreshed on key rotation)
- Access token expiry: 15 minutes. Refresh token: 7 days.
Multi-Tenant Considerations¶
This IS the multi-tenant foundation. Key design decisions: - ScopedValue is immutable once set — prevents accidental tenant context leaking - TenantAwareRepository pattern makes it impossible to forget tenant scoping - Dual DataSource ensures platform data and tenant data are logically separated even when physically on the same DB (MVP) - Profile C routing uses a pool cache with max 100 connections per tenant, 10 min idle timeout
Frontend Scope¶
No frontend in this slice. This is a backend infrastructure story.
Pages & Routes¶
N/A
Components¶
N/A
UI States (ALL REQUIRED)¶
N/A — Backend only.
Responsive Behavior¶
N/A
Interactions¶
N/A
Acceptance Criteria¶
AC-001: Tenant context propagation
Given a valid JWT with tenant_id="abc-123" and country_code="CI"
When any downstream service method accesses TenantContextHolder.current()
Then it receives TenantContext(tenantId="abc-123", countryCode="CI", dbProfile="SHARED")
AC-002: Missing JWT rejection
Given a request to /api/v1/employees without a JWT
When the request reaches the security filter chain
Then the response is HTTP 401 with body {"error": "UNAUTHORIZED", "message": "Authentication required"}
AC-003: Invalid tenant rejection
Given a valid JWT with tenant_id="nonexistent-uuid"
When the tenant resolution filter queries the platform DB
Then the response is HTTP 403 with body {"error": "TENANT_NOT_FOUND"}
AC-004: MDC logging includes tenant
Given a valid JWT request is processed
When any log statement is emitted during request processing
Then the log entry includes tenant_id and user_id in the MDC context
AC-005: Dual DataSource configuration
Given the application starts with platform and tenant DataSource configurations
When a repository marked as @PlatformScoped queries data
Then the query executes against the platform DataSource
And when a TenantAwareRepository queries data
Then the query executes against the tenant DataSource resolved for the current tenant
AC-006: Public endpoints accessible without JWT
Given no JWT is provided
When a request is made to /api/v1/onboarding/register
Then the request is not rejected by the security filter (authentication is not required)
AC-007: TenantAwareRepository enforces scoping
Given a repository extending TenantAwareRepository
When a query is executed
Then the query automatically includes WHERE tenant_id = :currentTenantId
And it is impossible to query data for a different tenant
OHADA & Regulatory Rules¶
- Tenant data isolation is existential: Cross-tenant data leakage (e.g., one company seeing another's employee salaries) would be a GDPR/Loi 2013-450 violation AND destroy customer trust. The TenantAwareRepository pattern must make this impossible.
- Loi n° 2013-450 (Art. 36): Data minimization — JWT should carry only the claims needed for authorization (user_id, tenant_id, country_code, roles), not personal data.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Spring Modulith boundaries, §Records & sealed types, §ScopedValue patterndocs/standards/database-guidelines.md— §Multi-tenant repository pattern, §Dual DataSource, §Connection poolingdocs/standards/api-guidelines.md— §Authentication, §Error response format
Testing Requirements¶
Unit Tests¶
- TenantContextHolder: set/get/clear lifecycle
- JwtTenantExtractor: valid JWT parsing, missing claims handling, malformed JWT
- TenantDataSourceRouter: correct DataSource resolution for Profile A and Profile C
Integration Tests¶
- Full request flow: JWT → TenantResolutionFilter → TenantContext set → repository query scoped
- Tenant isolation: create data for tenant A, request with tenant B JWT → zero results
- Public endpoint access without JWT
- Expired JWT rejection
- Concurrent requests with different tenant JWTs (verify no context leaking between virtual threads)
What QA Will Validate¶
- QA cannot directly test this slice (no UI). QA validates tenant isolation indirectly through all subsequent slices.
- DevOps validates: application starts correctly, connects to Keycloak, health check passes.
Out of Scope¶
- User entity and roles — that's CP-003
- Tenant entity and CRUD — that's CP-002
- Keycloak realm setup and configuration — infrastructure/DevOps task, not application code. Assume Keycloak is running and configured.
- Refresh token rotation — handled by Keycloak, not application code
- Rate limiting — V2
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px — N/A (backend only)
- [ ] All 5 UI states implemented (loading, empty, error, offline, success) — N/A
- [ ] French micro-copy matches design spec exactly — N/A
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] Offline write+sync works (Papillon) / N/A (ALTARYS Enterprise) — N/A (backend only)
- [ ] No TypeScript
any— all types explicit — N/A - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation — N/A (no data mutations in this slice)
Post-Implementation Note (2026-04-15 — chore/infra-tenant-context)¶
Defect fixed: During CP-001 implementation, TenantContext, TenantContextHolder, and
TenantAwareRepository were placed in platform.tenant.config / platform.tenant.repository.
These are internal sub-packages of the platform Spring Modulith module — inaccessible to data
plane modules (application.*, modules.*). This caused ModulithStructureTest to fail as soon
as any data plane service tried to import them.
Root cause: Spring Modulith only exposes the root package of a module as its public API. Sub-packages are internal by convention.
Fix: Moved the three classes to com.altarys.papillon.infrastructure — the root package of a
new dedicated shared infrastructure module (same pattern as com.altarys.papillon.events).
Corrected canonical locations:
- com.altarys.papillon.infrastructure.TenantContext
- com.altarys.papillon.infrastructure.TenantContextHolder
- com.altarys.papillon.infrastructure.TenantAwareRepository
All modules — platform, application.*, modules.* — must import from infrastructure, never
from platform.tenant.config.