Story AUDIT-001c: PiiHasher + per-tenant salt provisioning (envelope encryption)¶
Module: audit-log
Slice: AUDIT-001 (Architecture §11, split part c of a/b/c)
Side: [BACKEND]
Version target: V0.0.0
Priority: 4
Depends on: AUDIT-001a (audit_tenant_salt table + roles exist), AUDIT-001b (AuditEventBuilder Hashed<...> field type exists), TENANT-001 (TenantOnboarded domain event exists)
Can develop concurrently with: AUDIT-001b (different files; both merge after 001a)
Merge order: After AUDIT-001a
Estimated complexity: M
PRD User Stories: AUDIT-US-06 (pseudonymised PII in audit payloads)
Wireframe: N/A — backend only
Objective¶
Provide the PiiHasher bean — the single hashing primitive every producer uses to turn raw assuré PII (phone, name, email, DOB, ID-document number) into a deterministic, per-tenant-salted hash before it ever reaches an audit payload. Provision one secret salt per tenant at onboarding, stored with envelope encryption (a per-tenant data-encryption key wrapped by a platform key) following the D8 pattern. This honours law n°2013-450 minimisation while still letting platform admin reconstruct a case by hashing a candidate value and searching for it.
Backend Scope¶
Entities¶
- Writes rows into
audit_tenant_salt(table created in AUDIT-001a):tenant_idPK,salt_ciphertextBYTEA,wrapped_dekBYTEA,kek_versionSMALLINT (=1 in V0),created_at,rotated_at(NULL in V0 — rotation is V2).
Migrations¶
None new (the table ships in AUDIT-001a). This story populates rows at runtime.
Service Layer¶
All under root package com.altarys.papillon.pcs.controlplane.audit:
- PiiHasher (named-interface bean, ...audit.api) — the only callable hashing surface. String hash(String value): returns a hex SHA-256 of salt_bytes || UTF-8(canonical_form(value)). canonical_form lowercases + strips whitespace for emails, normalises phones to E.164. Producers receive a hex string and wrap it in the Hashed<...> type the AuditEventBuilder requires (AUDIT-001b). The hasher is consumed in the hot path from v0.0.0 onward.
- PiiHasherImpl (...audit.internal) — holds an in-memory per-tenant salt cache (TTL AUDIT_SALT_CACHE_TTL_SECONDS, default 300 s). On cache miss: SELECT salt_ciphertext + wrapped_dek for the current tenant, unwrap the DEK with the KEK (env AUDIT_KEK), AES-GCM-decrypt the salt, cache it. First call per tenant per JVM ~5–8 ms; cached calls ~10 µs (Architecture §8.1).
- TenantSaltProvisioner (...audit.internal) — @ApplicationModuleListener(TenantOnboarded.class). On a TenantOnboarded domain event: SecureRandom.nextBytes(32) → raw salt; SecureRandom.nextBytes(32) → DEK; AES-GCM-encrypt the salt with the DEK → salt_ciphertext; AES-KW-wrap the DEK with the KEK → wrapped_dek; INSERT the audit_tenant_salt row with kek_version = 1; zero the raw salt + DEK in the heap (Architecture §6.4).
API Endpoints¶
None.
Validation Rules¶
- FR-03 (pseudonymisation at write): a raw assuré PII value (phone, name, email, DOB, ID-document number) must be replaced with
hash_v1(value, salt[tenant_id])before persistence. In V0 the producer is responsible for hashing viaPiiHasher; theAuditEventBuilderHashed<...>field type makes hashing the only easy path (Risk R9). The defensive scan at the AUDIT INSERT boundary (which refuses a string that looks like a raw phone/IBAN/email) is out of V0 — deferred to V1 (AUDIT-008). - The salt is never persisted in cleartext, never logged, never put in an error envelope, never put in an export (AC-06.2).
Multi-Tenant Considerations¶
- Exactly one salt row per tenant; salts differ per tenant, so the same phone number hashes differently across tenants (verified by the canonical isolation test, Architecture §13.2). This is itself a tenant-isolation guarantee: a hash from tenant A cannot be matched against tenant B's audit rows.
audit_tenant_saltlives on the platform DB for every tenant including BYO (AUDIT-ADR-01).- The KEK (
AUDIT_KEK) is a single platform-level secret in V0 (env var). Per-tenant DEKs are wrapped by it. V1+ may move the KEK to a hosted KMS via the same envelope-encryption library.
Audit & Logging¶
- No new catalog event is emitted by this story. (Salt rotation would emit a V2-only event_type; not in V0.)
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-06.1: Allowed PII columns are hashed
Given an audit payload that would carry assuré personal data (full name, phone, email, DOB, ID-document number)
When it is built
Then the raw value is replaced with hash_v1(value, salt_tenant) and the corresponding customer_id is carried when known
AC-06.2: Salt is per-tenant and server-secret
Given the hashing salt
Then it is a per-tenant salt held in platform-managed secret storage (envelope-encrypted), and it never appears in source, in any log line, in any error envelope, or in any export
AC-06.5: Reconstruction works
Given a candidate phone value
When platform admin hashes it with salt_tenant and searches the audit_log
Then any matching event surfaces; matching is exact, not fuzzy
Compliance Rules¶
- Law n°2013-450 (CI data-protection) + ARTCI minimisation — pseudonymisation via deterministic hash + per-tenant server-secret salt is the V1 compensating control for assuré PII (AC-06, NFR-09). Status: TV-04 (temporarily validated; lawyer re-research pending) —
[LEGAL-REVIEW]applies to the lawful-basis claim. - AC-06.3 / AC-06.4 (producer-side conventions, not enforced here): operator
actor_user_id(Keycloak sub) is stored cleartext as a stable id; operator name/email are NOT put in the payload (joinable from AUTH at read time).client_ipanduser_agentare PII but kept cleartext under the §16.5 proof lawful basis. Producers must honour these; AUDIT only provides the hashing primitive. - Salt rotation + cryptographic erasure ("forget" a tenant by burning the salt) is V2 (PRD §A.6 OQ-02).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— named-interface beans,@ApplicationModuleListener, domain-event naming (TenantOnboarded, past tense, noEventsuffix).docs/standards/multi-tenant-model.md— per-tenant secret isolation.docs/standards/compliance-discipline.md—[LEGAL-REVIEW]for TV-04.docs/standards/critical-rules.md— PII minimisation rules.- Background:
docs/kb/audit-salt-storage-options.md(rationale for the salt-store choice — human-only KB, do not read programmatically).
Testing Requirements¶
Unit Tests¶
canonical_formnormalises emails (lowercase, trim) and phones (E.164).hash()is deterministic for the same(value, salt)across two JVM invocations (Architecture §13 PiiHasher determinism).- Wrapping/unwrapping a DEK with the KEK round-trips; decrypting the salt round-trips.
Integration Tests¶
TenantSaltProvisionercreates exactly oneaudit_tenant_saltrow on aTenantOnboardedevent.- AC-06.5 reconstruction: emit an event with a hashed phone, then hash the same raw phone and find the row by exact match.
- Tenant isolation (canonical): the same phone hashed under tenant A vs tenant B yields different hashes (Architecture §13.2).
- Salt never leaks: scan log output + error envelopes in a forced-failure test; assert no salt bytes present.
What QA Will Validate¶
- One salt per tenant, created at onboarding, never in cleartext anywhere.
- Hash determinism enables exact-match reconstruction.
- Cross-tenant hashes never collide for the same input.
Out of Scope¶
- Defensive PII scan at the AUDIT INSERT boundary (V1, AUDIT-008).
- Salt rotation,
kek_versionbump, cryptographic erasure (V2). - Moving the KEK to a hosted KMS (V1+).
- Any producer's decision about WHAT to hash — that lives in each producer story; AUDIT only provides
PiiHasher.
Definition of Done¶
- [ ]
PiiHasherbean exposed as a Modulith named-interface, consumable by every producer - [ ] Per-tenant salt provisioned on
TenantOnboarded(envelope-encrypted: DEK wrapped by KEK) - [ ] Hash is deterministic across JVM restarts (AC-06.5 reconstruction verified)
- [ ] Salt never persisted in cleartext / logged / in error envelope / in export (AC-06.2 verified)
- [ ] Same input hashes differently per tenant (tenant isolation verified)
- [ ]
audit_tenant_salton platform DB for both V1 DB profiles - [ ]
[LEGAL-REVIEW]note on TV-04 lawful basis retained - [ ] No silent failures: a missing/invalid
AUDIT_KEKfails fast; a missing salt row for a tenant raises a clear error rather than writing raw PII