net-kingdom/docs/audit-core-intake-identity-providers.md
tegwick e1cad11a80
All checks were successful
Bounded-execution contracts / contracts (push) Successful in 1m35s
Build and Publish bounded-executor / build-and-push (push) Successful in 12s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Identity provider journey acceptance / provider (push) Successful in 37s
Inbox 2026-11-07: executor tenant tenant:platform, audit-core pin 4da952c, P6 answers recorded
- AUDIT-WP-0020-T03: exercise_audit_sender emits tenant:platform and probes
  tenant:other as the wrong tenant (row E6); contract suite 131 passed
  against audit-core 4da952c.
- Intake doc: audit-core P6 answers (tenant, correlation_origin, privacyIDEA
  option b, no backfill, 30-day recoverability).
- NK-WP-0055-T11 note; NK-WP-0035-T04 and NK-WP-0054-T04 wait reasons now
  name the founder/next step after the 2026-11-03 owner deliveries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 149903@bnt-lap001
Assistant-Session: 4c20a536-1452-4a85-bc09-e1987d9817d6
2026-11-07 14:21:50 +01:00

472 lines
27 KiB
Markdown

---
title: "audit-core intake packages: Authelia, LLDAP, privacyIDEA"
status: draft
owner: net-kingdom
work: NK-WP-0055-T11
answers: audit-core AUDIT-WP-0017-T04 (three of the four identity providers; KeyCape is separate)
contract: canon/standards/audit-correlation_v0.1.md (accepted)
created: "2026-10-27"
---
# audit-core intake packages: Authelia, LLDAP, privacyIDEA (draft)
This document gives audit-core the intake package for each of the three identity providers that NetKingdom deploys:
- Authelia (`sso-mfa/k8s/authelia`);
- LLDAP (`sso-mfa/k8s/lldap`);
- privacyIDEA (`sso-mfa/k8s/privacyidea`).
It follows the common package in audit-core `AUDIT-WP-0017` ("Common intake package for every new sender") and
the wire contract in audit-core `docs/event-envelope.md`. KeyCape's package belongs to key-cape and is not covered here.
**Status.** Draft for audit-core review. It plans work only:
- it activates no sender;
- it mints no credential;
- it makes no live change.
**How each claim is marked:**
- **[repo]**: read from this repository's manifests.
- **[upstream]**: from upstream product behaviour as we know it. Not yet verified against the pinned image.
- **[estimate]**: a volume figure that must be measured before audit-core sets thresholds.
- **[unknown]**: an open question.
## 1. Choices that apply to all three providers
**Separate sources.** Each provider gets its own `source`, as AUDIT-WP-0017-T04 asks, so that silence in one of them
can be detected:
- `netkingdom.authelia`
- `netkingdom.lldap`
- `netkingdom.privacyidea`
**Owner.** The owning repo for all three sources is `net-kingdom`. ADR-0015 moves runtime packaging to `rapp-*`
repositories. If that move happens, the source name stays the same and only the owning repo changes.
**Tenant policy.** All three providers are platform-scoped: there is a single realm and directory, `dc=netkingdom,dc=local`.
- The envelope requires `tenant` to be a non-empty value.
- Use the fixed value `tenant:platform` for every event, and give the sender credential only `tenant:platform` as a
permitted tenant. Settled by the founder on 2026-11-07 (audit-core AUDIT-WP-0020); this replaces the `platform`
first proposed here.
**Shipper.** One small collector per provider: the *intake shipper*.
- It runs in the provider's namespace, either as a sidecar or as a CronJob.
- Each shipper has its own sender credential.
- It normalises native records into the envelope and posts them to `POST /v1/events`.
- On retry it reuses the same `id`, so delivery is idempotent.
**Envelope mapping:**
| Envelope field | Mapping rule |
|---|---|
| `id` and `Idempotency-Key` | A deterministic hash of the provider name and a stable native key (defined per provider below). Retries and re-reads after a restart produce the same id. |
| `type` | One value from the provider's action list below. The receiver stores it as `action`. |
| `subject` | The affected object as a **pseudonymous** reference, e.g. `user:<pseudonym>`, `group:<cn>` or `token:<serial>`. Never a raw username or email address. |
| `occurred_at` | The provider's native event time as RFC 3339 with an explicit `Z`. All three providers run in UTC. |
| `correlation_id` | The 32-hex trace id (AC-1) if a valid `traceparent` was seen. Otherwise a newly generated trace id, with `data.correlation_origin: generated` (AC-4). |
| `data` | Holds the outcome, the actor (pseudonymised), the native record id, `correlation_origin`, and the fields listed per provider. |
**Placement of `actor` and `outcome`.** The receiver fixes `actor=null` and `outcome=recorded` at ingest. The real
actor and outcome therefore go inside `data`:
- `data.outcome` is one of `success`, `failure` or `refused`.
- `data.actor` is the acting principal.
**Pseudonymisation of personal data.** The rules are the same for all three providers.
- **Usernames, emails and display names:**
- Replace them with a keyed pseudonym before sending: HMAC-SHA256 truncated to 32 hex characters.
- The key is a NetKingdom-held value in OpenBao and is never sent to audit-core.
- This keeps records from the three providers joinable on the same user without exposing the name.
- Raw names must never appear in `subject` or `id`.
- [unknown] Whether audit-core prefers its own per-subject encrypted personal payload (`docs/erasure-and-audit.md`)
instead. If so, the raw name may travel in that payload, and our pseudonym becomes only the join key.
- **Synthetic, test and break-glass accounts:** Service and break-glass account names (`admin`, the KeyCape bind
user, `identity-provisioner`) are operational identifiers, not personal data. They may be sent in clear. This is
what lets break-glass use stand out.
- **Client IP addresses:** Personal data. Send them as a truncated prefix (IPv4 /24, IPv6 /48) plus a keyed
pseudonym of the full address. Ban and regulation events need the full-address pseudonym for correlation.
[unknown] Whether security operations need the clear IP for 30 days. That would need an owner decision under a
separate retention period.
- **Never sent:**
- passwords and password attempts;
- OTP values;
- session cookies;
- JWTs;
- OIDC codes and tokens;
- client secrets;
- enrollment seeds and QR payloads;
- LDAP bind credentials.
The receiver's `secret_policy` is the backstop for these, not the control.
**Credential.** One sender credential per source:
- it is minted through the attended OpenBao path that `warden route find "audit-core sender credential"` points to;
- it is delivered to the shipper as a mounted file;
- audit-core never holds or prints it;
- `may_read` is false.
**Retention.** The default applies: a 30-day query window plus archive. privacyIDEA records are the exception
(section 4.5).
**Heartbeat.** Each shipper emits `type: source.heartbeat` every 15 minutes, even when there are no events. The
heartbeat's `data` carries:
- the shipper's cursor (last native id or log offset);
- the count of records read and sent since the last heartbeat.
A heartbeat with a stalled cursor while the provider is serving traffic is the completeness signal (audit-core
`docs/stream-completeness.md`).
## 2. Authelia: `netkingdom.authelia`
### 2.1 Registration
| Item | Value |
|---|---|
| Deployment | Namespace `sso`, Deployment `authelia`, selector `app.kubernetes.io/name=authelia`, 1 replica [repo] |
| Image | `authelia/authelia` 4.39.28, pinned by digest [repo] |
| Emitting identity | A shipper ServiceAccount `audit-shipper-authelia` in `sso`. Proposed; it does not exist yet. |
| Role | First-factor login (Authelia checks the password against LLDAP), forward-auth for `activity`/`temporal`, and the OIDC upstream for client `keycape` [repo] |
| Exposure | Behind the Traefik Ingress at `auth.coulomb.social` and `login.coulomb.social` [repo] |
### 2.2 Where the events are
**1. Structured logs.**
- `log.level: info`. No `log.format` is set, so output is text [repo].
- Proposal: set `log.format: json`. The schema is unchanged, and this is a small config change.
- At info level, Authelia logs these events [upstream]:
- unsuccessful first-factor and second-factor attempts, with the username and remote IP;
- regulation bans;
- authorization denials;
- OIDC authorize and token errors.
- Successful logins are not reliably logged at info [upstream].
**2. The `authentication_logs` table in Authelia storage.**
- Storage here is SQLite at `/var/authelia/data/db.sqlite3` on the `authelia` PVC [repo].
- Each row records [upstream]:
- one first-factor or second-factor attempt, success or failure;
- username, `auth_type`, `remote_ip`, `request_uri` and `request_method`;
- a monotonic `id`.
- Since 4.38, the `banned_user` and `banned_ip` tables also exist [upstream].
- This is the **authoritative** source for login outcomes. Logs supplement it for OIDC and authorization events.
### 2.3 Collection
Recommended: a **sidecar** in the `authelia` pod with two read-only inputs.
**(a) The SQLite file, read-only:**
- The sidecar polls `authentication_logs` and the ban tables by `id`, with a cursor kept on a small shipper PVC.
- SQLite's WAL mode allows a concurrent read-only reader [upstream].
- The sidecar opens the database with `mode=ro` and never writes to it.
**(b) The container stdout, with `log.format: json`:**
- The sidecar reads it through a shared `emptyDir` log file. This needs a `--log.file_path` setting [upstream].
- Alternative: a node-level collector, if railiance-cluster provides one. [unknown]
**Native key for `id`:**
- Table rows: `authelia|authentication_logs|<id>`.
- Log lines: `authelia|log|<sha256 of the raw JSON line>`.
**Constraint.** Storage is a single-writer SQLite file on an RWO PVC, so the sidecar must run in the same pod.
[unknown] Whether a planned move of Authelia storage to PostgreSQL would replace (a) with a DB reader.
### 2.4 Correlation
- Browser traffic arrives through Traefik. Today Authelia neither reads nor logs a `traceparent`, and it does not
propagate one [upstream].
- When KeyCape redirects the user to Authelia, that is a browser navigation, so any trace context is lost.
- **Default:** `correlation_origin: generated`. The shipper creates one trace id per record.
- **Improvement, needs work by railiance-cluster:**
- Enable the Traefik access log in JSON with the `traceparent` request header kept. Header names only; never `Cookie` or `Authorization`.
- Join on time, router and client IP to recover propagated ids for OIDC back-channel calls from KeyCape: `/api/oidc/token` and `/api/oidc/userinfo`.
- Only server-to-server calls can carry an inbound `traceparent`.
- [unknown] Whether Traefik tracing or header logging is enabled on railiance01.
- **Same login, two records:** The OIDC `authorize` → `token` exchange is the hop KeyCape shares with Authelia.
- The KeyCape issuance record holds the propagated id.
- The Authelia record for the same login can carry it only if KeyCape sends `traceparent` on the token call **and** Traefik logs it.
### 2.5 Action list (scope rows)
| `type` | From | `data` beyond the common fields |
|---|---|---|
| `auth.first_factor.success` / `.failure` | `authentication_logs` (1FA) | `auth_method: password`, `request_host`, `ip_prefix`, `ip_pseudonym` |
| `auth.second_factor.success` / `.failure` | `authentication_logs` (2FA rows; TOTP/WebAuthn/Duo if ever enabled) | `auth_type` |
| `regulation.user_banned` | Ban table or log | `ban_until`, `failed_count` |
| `regulation.ip_banned` | Ban table (4.38+) | `ban_until` |
| `regulation.ban_lifted` | Ban table `revoked` / expiry | `by` (admin or expiry) |
| `access.denied` | Log, authz deny | `request_host` (e.g. `activity.coulomb.social`), `rule` |
| `oidc.authorize.success` / `.failure` | Log | `client_id` (`keycape`), `scopes`, `error` |
| `oidc.token.issued` / `.failure` | Log | `client_id`, `grant_type`. **No token values.** |
| `oidc.consent.granted` | Log (implicit consent today) | `client_id` |
| `identity.reset_password.requested` / `.completed` | Log, notifier | (Password reset is via LLDAP/Authelia. [unknown] Whether it is enabled.) |
| `source.heartbeat` | Shipper | cursor, counts |
### 2.6 Volume, cadence and retention
- **Volume** [estimate]: a few hundred authentication rows per day across operators, demo users and probes. Peak
under 5 events/s, except during a brute-force attempt.
- **Brute force:** Regulation caps each username at 5 failures per 2 minutes. IP-spread attacks are not capped.
Proposed shipper rate limit: 50 events/s, with overflow counted in the heartbeat. Overflow is never silently dropped.
- **Heartbeat:** every 15 minutes.
- **Allowed silence:** 30 minutes of shipper silence. Gaps between authentication events are normal at night.
- **Retention:** default. [unknown] Whether 90 days is wanted for ban events.
## 3. LLDAP: `netkingdom.lldap`
### 3.1 Registration
| Item | Value |
|---|---|
| Deployment | Namespace `sso`, Deployment `lldap`, 1 replica. Ports: LDAP 3890, HTTP/GraphQL 17170 [repo] |
| Image | `lldap/lldap:stable`. **Not pinned** [repo]. Pinning it is a prerequisite for a stable log format. |
| Storage | Default SQLite on the `lldap` PVC. No `LLDAP_DATABASE_URL` is set [repo]. |
| Emitting identity | Shipper ServiceAccount `audit-shipper-lldap` in `sso` (proposed) |
| LDAP clients | Authelia and KeyCape bind as `uid=admin` [repo, Authelia config]. Admin automation: `lldap_admin.py`, `identity-provisioner-reconcile.py`, `break-glass.sh` [repo] |
### 3.2 Where the events are
- **Logs.** LLDAP has no audit table. Its only audit surface is the `tracing` log on stdout [upstream].
- **At default verbosity, LLDAP logs [upstream]:**
- LDAP bind attempts with the bind DN and success or failure;
- GraphQL mutations, i.e. admin changes:
- create, update and delete user;
- create and delete group;
- add and remove a member;
- password change through the OPAQUE flow.
- **Field coverage at default verbosity** [unknown]:
- Which fields appear without `verbose=true` must be verified against the pinned image.
- `verbose=true` logs too much, including search filters that contain usernames.
- **Logins to the web UI** (`/auth/opaque/login/*`, `/auth/simple/login`) are logged with the username [upstream].
### 3.3 Collection
- Recommended: a log **sidecar**, or a node log collector, reading the `lldap` container's stdout.
- **Parsing:**
- LLDAP logs are text spans, not JSON [upstream].
- The shipper keeps a strict per-version parser and an allowlist of message patterns.
- A line it does not recognise is counted in the heartbeat and never guessed.
- **Native key for `id`:** `lldap|<pod uid>|<container restart count>|<line sequence number>`. This stays stable
when the shipper re-reads after its own restart. [unknown] Whether the collector exposes a stable offset. If not,
use a sha256 of pod uid, timestamp and raw line.
- **Cross-check through admin tooling:**
- The repo's admin tools (`lldap_admin.py`, `manage-group-members.sh`, `create-user.sh`) could emit their own
events, as the actor's view of the change.
- Proposal: not in this first package. Rely on the server log so that changes made outside the tools are captured too.
### 3.4 Correlation
- **LDAP (3890):** There are no headers, so `correlation_origin: generated` always (AC-2). The calling component
(Authelia or KeyCape) records its own inbound id. LLDAP records only its own.
- **HTTP/GraphQL (17170)** is behind Traefik (`ingress.yaml`). LLDAP does not log `traceparent` [upstream]. The same
Traefik access-log join as for Authelia could recover it for admin API calls. [unknown]
- **Joining LDAP records:** A bind from Authelia happens within milliseconds of an Authelia `auth.first_factor.*`
record for the same user pseudonym. audit-core may record a `parent_event_id` link only if the shipper can make it
deterministically. Proposal: do not; a time-and-pseudonym join is analysis, not correlation.
### 3.5 Action list
| `type` | Notes |
|---|---|
| `ldap.bind.success` / `.failure` | `bind_dn` (service DN in clear; a user DN is pseudonymised), `client_ip_prefix`. Failures are the main signal. |
| `ldap.bind.service` | Optional. A successful bind by the `uid=admin` service account, which is expected high-volume noise. Proposal: count these in the heartbeat instead of sending one event per bind. |
| `user.created` / `user.updated` / `user.deleted` | `actor` (admin principal), changed attribute **names** only |
| `user.password_changed` | `actor`, `self` true/false. No password material. |
| `group.created` / `group.deleted` | `group` cn in clear (group names are operational) |
| `group.member_added` / `group.member_removed` | `group`, member pseudonym, `actor` |
| `admin.login.success` / `.failure` | Web UI or API login |
| `source.heartbeat` | Includes `service_bind_count` and `unparsed_line_count` |
### 3.6 Volume, cadence and retention
- **Volume** [estimate]:
- Service binds: from hundreds up to a few thousand per day. They follow Authelia logins and KeyCape lookups, and
appear only as counts.
- Events actually sent: under 200 per day.
- Admin changes: a handful per week, with bursts during reconciliation runs (`identity-provisioner-reconcile.py`).
- **Peak:** under 5 events/s.
- **Heartbeat:** every 15 minutes.
- **Allowed silence:** 30 minutes.
- **Retention:** default. Group-membership changes are authorization-relevant. [unknown] Whether audit-core should
keep them for longer.
## 4. privacyIDEA: `netkingdom.privacyidea`
### 4.1 Registration
| Item | Value |
|---|---|
| Deployment | Namespace `mfa`, Deployment `privacyidea`, 1 replica [repo] |
| Image | `ghcr.io/gpappsoft/privacyidea-docker:3.12.2` [repo] |
| Database | PostgreSQL cluster `net-kingdom-pg`, namespace `databases`. Audit uses the default SQL audit module [repo, README; `PI_AUDIT_MODULE` not set, so the default `sqlaudit` applies, upstream] |
| Audit signing | `PI_AUDIT_KEY_PRIVATE` / `PI_AUDIT_KEY_PUBLIC` at `/etc/privacyidea/{private,public}.pem`, from Secret `privacyidea-auditkeys` [repo] |
| Callers | KeyCape (MFA challenge on `/authorize/callback`), NetKingdom admin scripts (`pi_api.py`, `check-user-mfa-state.sh`, onboarding guard) [repo] |
| Emitting identity | Shipper ServiceAccount `audit-shipper-privacyidea`, with a **read-only** PostgreSQL role on the audit table only (proposed) |
### 4.2 Where the events are
**The table.** The signed audit table `pidea_audit` in PostgreSQL [upstream schema, 3.x]. Each request writes one
row with these columns:
- `id`, `date`, `startdate`, `duration`
- `action` (e.g. `POST /validate/check`), `success`, `action_detail`, `info`
- `serial`, `token_type`
- `user`, `realm`, `resolver`, `administrator`
- `client` (IP), `privacyidea_server`
- `policies`, `container_serial`
- `signature`
**The signature.** Each row is signed with the audit private key.
**Proof of the signed history (NK-WP-0049-T20, 2026-10-16):**
- All 279,209 stored rows verified against the retained original audit key pair. None were unsigned, invalid or legacy.
- After restore, a new entry was signed with the preserved identity.
- Tampered rows and a wrong key were refused.
**Consequence.** The audit key pair is a retained identity:
- It must never be regenerated to repair anything (`KEY-RECOVERY.md`).
- The public key is the verification anchor that audit-core needs.
**The application log** at `/var/log/privacyidea/privacyidea.log` (`PI_LOGLEVEL=20`) [repo] duplicates part of this
and is not signed. Out of scope.
### 4.3 Collection: database export with the signature preserved
**Export method.** An exporter (CronJob or sidecar) reads the table:
- `SELECT ... FROM pidea_audit WHERE id > :cursor ORDER BY id LIMIT 500`
- It runs under a dedicated read-only role.
- The cursor is kept on the shipper side and echoed in the heartbeat.
**What each row becomes:**
- `id` = sha256 of `privacyidea|pidea_audit|<id>|<signature>`.
- The signature is included so that a restored database with re-used ids cannot collide silently with earlier events.
- [unknown] Whether a restore can roll ids back. T20 restored into an isolated target, not production.
- `data.native` holds **every signed column, unchanged**, plus `signature`.
- `data.native_signature_alg` holds the privacyIDEA version and audit module.
- `data.verification_key_fingerprint` holds the SHA-256 of the public key.
**Personal data in the signed row.** The `user`, `client`, `administrator` and `info` columns can contain usernames and IPs.
- Pseudonymising them in `data.native` would break the signature.
- **Proposed resolution:** send them in audit-core's per-subject encrypted personal payload, if one exists (`docs/erasure-and-audit.md`), so the row stays verifiable.
- Use pseudonymous copies in the plain `data`, `subject` and `actor` fields.
- **Owner decision needed:** if audit-core cannot hold an encrypted personal payload, we must choose one of:
- (a) keep raw columns in the signed payload and accept them under audit-core's PII handling; or
- (b) send only pseudonymised fields plus the signature and the row hash. Verification would then be possible only
against the source database while it is retained.
**Verification by audit-core** (proposal):
- Store the public key once as source metadata.
- The shipper verifies each row before sending. A failed row is sent as `type: audit.signature_invalid` with the row
id and never dropped.
- audit-core can re-verify a sample.
- [unknown] Whether privacyIDEA's signed-string format is stable across 3.12.x. Pin the exporter to the image version.
**Not used: privacyIDEA's logger or container audit module** (`PI_AUDIT_MODULE` set to a logger-based module) [upstream].
- It would stream audit records to a log handler.
- Its records are **not signed** and would bypass the T20 continuity property.
- Rejected as the primary path; possibly useful later as a low-latency mirror.
### 4.4 Correlation
- privacyIDEA 3.12 neither records nor propagates `traceparent` [upstream]. `pidea_audit` has no column for it.
- Default: `correlation_origin: generated`.
- **Improvement:** KeyCape's MFA call (`/validate/check`) is server-to-server. If KeyCape sends `traceparent` and the
privacyIDEA Ingress/Traefik access log records it, the shipper can join on these fields:
- `startdate`;
- `client` (KeyCape pod IP);
- `serial` / `user`.
The join recovers the propagated id. This is the same dependency on railiance-cluster as for Authelia. [unknown]
- **Cluster-internal calls:** If KeyCape calls privacyIDEA by cluster-internal Service address, the call bypasses
Traefik. Then only a privacyIDEA-side WSGI middleware could log the header. That is an upstream or plugin change.
[unknown] Which address KeyCape uses in production.
### 4.5 Action list
The `type` is derived from `action`, the HTTP method plus endpoint:
| `type` | Native `action` | Notes |
|---|---|---|
| `mfa.challenge.success` / `.failure` | `POST /validate/check`, `/validate/samlcheck` | `serial`, `token_type`, user pseudonym, `realm` |
| `mfa.challenge.triggered` | `POST /validate/triggerchallenge` | |
| `token.enrolled` / `token.enrollment_confirmed` | `POST /token/init` (2-step) | No seed or QR material; `info` is checked for secret-shaped values |
| `token.deleted` / `.disabled` / `.enabled` / `.revoked` / `.reset` / `.unassigned` | `DELETE /token/<serial>`, `POST /token/disable` etc. | `administrator` in `actor` |
| `token.resync` / `.setpin` | `POST /token/resync`, `/token/setpin` | |
| `policy.changed` / `policy.deleted` | `POST/DELETE /policy/*` | Admin |
| `config.changed` | `POST /system/*`, `/resolver/*`, `/realm/*`, `/caconnector/*` | Admin |
| `admin.auth.success` / `.failure` | `POST /auth` | Includes the bootstrap-admin and script logins |
| `audit.read` | `GET /audit/` | Who read the audit log |
| `audit.signature_invalid` | Exporter | Never dropped |
| `privacyidea.other` | Any other `action` | Counted and sent, so the action list can be refined |
| `source.heartbeat` | Exporter | Cursor, `max(id)`, `rows_behind` |
### 4.6 Volume, cadence and retention
- **Volume** [estimate]:
- Average: about 1,300 rows per day. That is 279,209 rows over about 210 days, from deployment on 2026-03-19 to the T20 count on 2026-10-16.
- The rows are probably dominated by automation: MFA-state checks, the guard, probes.
- [unknown] The split between automated calls and human logins. Measure it per `action` before setting thresholds.
- **Peak:** under 10 rows/s.
- **Backfill:** Exporting the historical rows is a one-off backfill of about 280k rows.
- Proposal: run it in batches under 50 events/s.
- The cursor starts at `id=0`.
- [unknown] Whether audit-core wants the history or only the rows from activation onwards.
- **Heartbeat:** every 15 minutes, with `rows_behind`.
- **Allowed silence:** 30 minutes. The automated checks make zero rows over several hours anomalous.
- **Retention.** A longer retention is requested.
- MFA token lifecycle and admin policy changes are the evidence behind privileged-access claims (SI-X4, aal2).
- Proposal: the default 30-day query window plus an archive that keeps signed rows for at least 1 year.
- The source database stays authoritative. [unknown] Its retention and rotation are not configured in the repo today.
## 5. Prerequisites and owner asks
| # | Item | Owner |
|---|---|---|
| P1 | Pin the `lldap/lldap` image to a digest | net-kingdom (or the `rapp-*` owner after ADR-0015) |
| P2 | Set Authelia `log.format: json` and a file path the sidecar can read | net-kingdom |
| P3 | Read-only PostgreSQL role for `pidea_audit` | rapp-postgres / railiance-platform with net-kingdom |
| P4 | Pseudonymisation key in OpenBao, and a sender credential for each source | net-kingdom via the attended OpenBao path (`warden route`) |
| P5 | Traefik JSON access log with `traceparent` kept (no cookies or auth headers) | railiance-cluster |
| P6 | Answered 2026-11-07, see below | audit-core |
| P7 | KeyCape sends `traceparent` on its back-channel calls to Authelia and privacyIDEA | key-cape |
### audit-core answers to P6 (2026-11-07, founder decisions recorded in AUDIT-WP-0017-T04)
- **Tenant:** `tenant:platform` for all three sources (AUDIT-WP-0020). This corrects audit-core's first answer
of `platform`.
- **`correlation_origin`:** in `data.correlation_origin`, next to `data.actor` and `data.outcome`; the envelope is
unchanged. Senders opt in with `"correlation_contract": "audit-correlation-v0.1"` (audit-core `364e723`,
`docs/event-envelope.md`, "The correlation contract"). It is not live until the next receiver image.
- **Signed privacyIDEA rows:** option (b). audit-core has no per-subject encrypted payload (R2, no
crypto-shredding). Send pseudonymised `data`, `subject` and `actor`, plus `signature`, `native_signature_alg`,
`verification_key_fingerprint` and a row hash, never the raw signed columns. The raw row is verified against the
source database while it is retained. The legal basis for keeping raw personal data stays with risk-nexus.
- **Backfill:** none. The cursor starts at `max(id)` on activation.
- **Retention:** audit-core is append-only but recoverable for 30 days only (`data.backup`) and has no archive class
(`data.archive` unprocured, reviewed 2026-12-31). The privacyIDEA database therefore stays authoritative for the
1-year horizon, and its retention must be at least that long.
- **Shipper:** one shipper and one sender credential per source; audit-core prepares a per-source ingress
NetworkPolicy with each overlay.
Still owed by NetKingdom before audit-core prepares overlays: the section 6 unknowns, P1-P5 and P7, then a
per-provider registration (exact source, selectors, cadence, heartbeat class).
## 6. Unknowns to verify before overlays are prepared
1. **Log content.** Exact Authelia 4.39.28 and LLDAP log messages and fields at info level. Capture them on synthetic
accounts in a non-production instance.
2. **Concurrent SQLite reads.** Whether Authelia's SQLite storage tolerates a concurrent read-only reader on the RWO PVC
without lock contention. The alternative is to move Authelia storage to PostgreSQL.
3. **Signed-string format.** The privacyIDEA signed-string format and its stability across patch releases.
Exporter-side verification depends on it.
4. **Ingress path and logging.** Whether KeyCape reaches Authelia and privacyIDEA through Traefik or through cluster
Services, and whether Traefik logs request headers on railiance01.
5. **Traffic split and volume.** The real distribution per action and the human-versus-automation split. Every volume
figure above is an estimate.
6. **Password reset.** Whether Authelia password reset is enabled. The notifier is configured, but the reset policy is
not set explicitly in `configmap.yaml`.
7. **Deployed config.** Whether the deployed configuration still matches the repo manifests. ADR-0015 migration and
live drift are not checked in this draft.