Story AUDIT-001a: Audit schema, Postgres roles, and schema-migration self-event¶
Module: audit-log
Slice: AUDIT-001 (Architecture §11, split part a of a/b/c)
Side: [BACKEND]
Version target: V0.0.0
Priority: 2
Depends on: AUDIT-000 (dev env), TENANT-001 (the tenant table is referenced by audit_tenant_salt / audit_export foreign keys)
Can develop concurrently with: None within AUDIT-001 (001b and 001c build on this schema)
Merge order: Before AUDIT-001b and AUDIT-001c
Estimated complexity: M
PRD User Stories: AUDIT-US-05 (DB-level append-only), AUDIT-US-08 (self-auditing — schema migration part)
Wireframe: N/A — backend only
Objective¶
Create the complete V1 audit_log schema on the platform DB on day one (frozen — no ALTER TABLE audit_log allowed between v0.0.0 and v0.1.0), plus the two supporting tables (audit_tenant_salt, audit_export) and the three PostgreSQL roles that make the audit table append-only at the database level. Also wire the Flyway Java callback that writes one AUDIT_SCHEMA_MIGRATED audit row for every migration, under a deploy-only role the running application can never use. This story is the structural backbone every producer and reader depends on.
Backend Scope¶
Entities¶
Three tables, all on the platform DB. Column types are copied verbatim from PRD §B.5 / Architecture §2 — do not change them.
audit_log (PRD §B.5):
- event_id UUID PK (UUIDv7, time-ordered)
- event_type TEXT NOT NULL (one of the §B.1 catalog; gate is enforced in the application layer, not as a DB CHECK)
- event_schema_version SMALLINT NOT NULL (starts at 1 per event_type)
- business_committed_at TIMESTAMPTZ NOT NULL (producer commit time — source of truth for ordering + retention)
- audit_persisted_at TIMESTAMPTZ NOT NULL DEFAULT now()
- tenant_id UUID (NOT NULL except for AUDIT_SCHEMA_MIGRATED)
- acting_on_tenant_id UUID (platform admin acting on a tenant)
- agency_id UUID (optional)
- country_code CHAR(2) (snapshotted from tenant at write time)
- producer_module TEXT NOT NULL (TENANT|AUTH|ING|OP|CP|PAY|NOTIF|AUDIT|deploy)
- producer_outbox_id TEXT (FR-06 idempotence key)
- actor_user_id TEXT (Keycloak sub, or NULL for system actors)
- correlation_id UUID (optional)
- customer_id UUID (optional)
- contract_id UUID (optional)
- signed_link_id UUID (optional)
- result TEXT NOT NULL CHECK (result IN ('SUCCESS','FAILURE','INFO'))
- client_ip INET (optional)
- user_agent TEXT (optional, truncated to 500 chars by the writer)
- payload JSONB NOT NULL DEFAULT '{}'::jsonb (PII pseudonymised by producers — see AUDIT-001c)
- Two table CHECKs: event_type <> 'AUDIT_SCHEMA_MIGRATED' OR tenant_id IS NULL and event_type = 'AUDIT_SCHEMA_MIGRATED' OR tenant_id IS NOT NULL
audit_tenant_salt (Architecture §2.4): tenant_id UUID PK REFERENCES tenant(id) ON DELETE RESTRICT, salt_ciphertext BYTEA NOT NULL, wrapped_dek BYTEA NOT NULL, kek_version SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ. (Rows are populated in AUDIT-001c; the table is created here.)
audit_export (Architecture §2.5): export_id UUID PK, requested_by TEXT NOT NULL, tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT, date_range_start TIMESTAMPTZ NOT NULL, date_range_end TIMESTAMPTZ NOT NULL, scope_event_types TEXT, scope_correlation_id UUID, status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','RUNNING','COMPLETED','FAILED')), event_count BIGINT, jsonl_object_key TEXT, manifest_object_key TEXT, jsonl_sha256 TEXT, error_message TEXT, requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ. (Worker fills this in AUDIT-003; the table is created here so the full schema is frozen day one.)
Migrations¶
All under backend/src/main/resources/db/migration/platform/ (platform DB only, never tenant DB):
- V001__audit_log_init.sql — CREATE TABLE audit_log (full §B.5 columns + the two CHECKs above).
- V002__audit_tenant_salt.sql — CREATE TABLE audit_tenant_salt.
- V003__audit_export.sql — CREATE TABLE audit_export + audit_export_tenant_requested_idx (tenant_id, requested_at DESC).
- V004__audit_roles.sql — create roles and grants (see Service Layer).
- V005__audit_indexes.sql — all audit_log indexes:
- audit_log_tenant_ts_idx (tenant_id, business_committed_at DESC)
- audit_log_actor_ts_idx (actor_user_id, business_committed_at DESC) WHERE actor_user_id IS NOT NULL
- audit_log_correlation_idx (correlation_id) WHERE correlation_id IS NOT NULL
- audit_log_customer_idx (customer_id) WHERE customer_id IS NOT NULL
- audit_log_contract_idx (contract_id) WHERE contract_id IS NOT NULL
- audit_log_signed_link_idx (signed_link_id) WHERE signed_link_id IS NOT NULL
- audit_log_producer_dedup_uk (producer_module, producer_outbox_id) UNIQUE WHERE producer_outbox_id IS NOT NULL (FR-06)
- audit_log_acting_tenant_idx (acting_on_tenant_id, business_committed_at DESC) WHERE acting_on_tenant_id IS NOT NULL
Service Layer¶
- Three PostgreSQL roles (Architecture §7.2, AUDIT-ADR-07), created in
V004__audit_roles.sql: papillon_app—GRANT INSERT, SELECT ON audit_log;SELECT ON audit_tenant_salt;SELECT, INSERT, UPDATE ON audit_export. Explicitly NO UPDATE, NO DELETE onaudit_log. Used by the Spring Boot runtime.papillon_audit_admin—GRANT ALL incl. DDLon all three tables. Used by Flyway at deploy time only; the runtime JVM never receives these credentials.papillon_audit_reader—SELECT only. Created here for forward-compat but not consumed in V0 (the query API arrives in V1). May be deferred to the V1 query story if the team prefers; documented either way.AuditSchemaMigratedCallback(...audit.internal) — a Flyway Java callback onAFTER_EACH_MIGRATE. Runs inside the migration transaction underpapillon_audit_admin. INSERTs exactly oneaudit_logrow withevent_type = 'AUDIT_SCHEMA_MIGRATED',producer_module = 'deploy',tenant_id = NULL,result = 'SUCCESS', and a payload of{migration_id, description, checksum}. If the callback throws, the whole migration rolls back (no partial state). See Architecture §9.2 for the reference implementation.
API Endpoints¶
None.
Validation Rules¶
resultconstrained by DB CHECK toSUCCESS|FAILURE|INFO.AUDIT_SCHEMA_MIGRATEDis the only event_type permitted to carrytenant_id = NULL; all other rows must have a non-nulltenant_id(enforced by the two table CHECKs).papillon_apphas no DDL, no UPDATE, no DELETE onaudit_log— verified by role grants, not by trigger.
Multi-Tenant Considerations¶
- Every
audit_logrow carriestenant_id(NULL only forAUDIT_SCHEMA_MIGRATED).country_codeis snapshotted per row so an exported event is self-contained (no join needed). - All three tables live on the platform DB for every tenant, including BYO-DB tenants (AUDIT-ADR-01). No AUDIT migration ever runs on a tenant's own DB. Both V1 DB profiles (SHARED, BYO) therefore behave identically for audit storage — verify the Flyway config targets only
db/migration/platform/. audit_tenant_salt.tenant_idandaudit_export.tenant_idare FKs totenant(id)withON DELETE RESTRICT, blocking tenant hard-delete (belt-and-suspenders over the CIMA/ARTCI retention rule).
Audit & Logging¶
- Emits
AUDIT_SCHEMA_MIGRATED(PRD §B.1, AC-08.3) once per applied migration, written by the deploy pipeline role only. The running application physically cannot write this event_type (closed at both the application boundary, FR-01, and the Postgres role level).
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-05.1: App role is INSERT-only on audit_log
Given the application database role papillon_app
When it attempts UPDATE or DELETE on audit_log
Then PostgreSQL rejects the statement with "permission denied"
AC-08.3: Schema migrations are logged out-of-band
Given a CR-approved Flyway migration that runs against audit_log
When the migration completes
Then exactly one AUDIT_SCHEMA_MIGRATED row appears, written by papillon_audit_admin, carrying the migration id and checksum
And the running application (papillon_app) has no code path able to write that event_type
AC-05.3: DDL guard (process)
Given a migration that changes the audit_log schema
When it is proposed
Then it must be reviewed in CR with the "audit-schema" label and signed off by the founder; schema migration is the only sanctioned path to alter the table
Compliance Rules¶
- CIMA Reg. 01-24 §16.5 —
audit_logis the immutable proof base. Append-only enforced via role grants (AUDIT-US-05, NFR-07). StatusVÉRIFIÉfor the minimal-field requirement (id, timestamp, user, nature, result — CIMA art. 37). - AC-05.4 honest scope — V0/V1 append-only defends against application bugs and compromised app credentials, not against a malicious DBA holding
papillon_audit_admin. Cryptographic tamper-evidence (hash chain / WORM) is V2. This must be reflected in any customer-facing §16.5 claim so it is not over-stated. - CI fiscal + CIMA 10-year retention (AC-07.1, AC-07.4) — structurally honoured here: no DELETE grant on
papillon_app, and no purge tool exists in V0.[LEGAL-REVIEW]: the retention duration for application/server logs isPRÉLIMINAIREin memo F5; the 10-year floor for insurance + e-money events isVÉRIFIÉ.
Standards & Conventions¶
docs/standards/database-guidelines.md— Flyway naming, platform-only migrations, append-only role pattern.docs/standards/multi-datasource-patterns.md— platform vs tenant DB; AUDIT targetsplatform/exclusively.docs/standards/critical-rules.md— rule 1 (tenant_id on every row), rule 3 (audit catalog).docs/standards/compliance-discipline.md—[LEGAL-REVIEW]handling for retention durations.docs/standards/java-spring-guidelines.md— Spring Modulith internal package conventions.
Testing Requirements¶
Unit Tests¶
AuditSchemaMigratedCallbackbuilds the correct INSERT payload fromMigrationInfo.
Integration Tests¶
- AC-05.1: connect as
papillon_app, attemptUPDATE audit_log SET result='FAILURE'andDELETE FROM audit_log— both raiseSQLExceptioncontaining "permission denied". - AC-08.3: run a Flyway migration touching
audit_log; assert exactly oneAUDIT_SCHEMA_MIGRATEDrow exists,producer_module='deploy',tenant_id IS NULL, payload carriesmigration_id+checksum. - Callback rollback: force the callback to throw; assert the migration is fully rolled back (no schema change, no audit row).
- Tenant isolation (schema-level): insert rows for two tenants directly; assert each is selectable only when filtered by its
tenant_id.
What QA Will Validate¶
- All five migrations apply cleanly on a fresh platform DB.
papillon_appcannot UPDATE/DELETEaudit_log.- One
AUDIT_SCHEMA_MIGRATEDrow per migration. - The full §B.5 column set exists day one (no follow-up
ALTER TABLE audit_logis needed by AUDIT-001b/001c/002/003).
Out of Scope¶
- Outbox listener,
AuditEventBuilder, catalog enum, producer stubs (AUDIT-001b). - PiiHasher and salt provisioning logic (AUDIT-001c) — the
audit_tenant_salttable is created here but populated there. - Export worker (AUDIT-003) — the
audit_exporttable is created here but filled there. papillon_audit_readerconsumption (V1 query API).- Active 10-year purge tool (V2).
- Cryptographic tamper-evidence / WORM (V2).
Definition of Done¶
- [ ] All five platform-DB migrations (
V001..V005) apply cleanly - [ ]
audit_logcarries the full PRD §B.5 column set (schema frozen for V0) - [ ]
papillon_apprejects UPDATE/DELETE onaudit_log(AC-05.1 verified) - [ ]
AUDIT_SCHEMA_MIGRATEDwritten once per migration bypapillon_audit_admin(AC-08.3 verified) - [ ] Callback failure rolls back the entire migration (no partial state)
- [ ] Tenant isolation verified (
tenant_idpresent; both V1 DB profiles route audit to platform DB) - [ ] Audit log entry present for the required event (
AUDIT_SCHEMA_MIGRATED) - [ ]
[LEGAL-REVIEW]note on retention duration retained in the code/migration comments - [ ] No silent failures: a failed migration or callback aborts the deploy