--- 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. - Proposal: use the fixed value `platform` for every event, and give the sender credential only `platform` as a permitted tenant. - [unknown] Is `platform` the spelling audit-core uses for platform-scoped sources? If not, use audit-core's spelling. **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:`, `group:` or `token:`. 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|`. - Log lines: `authelia|log|`. **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|||`. 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||`. - 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/`, `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 | `tenant` value for platform-scoped sources; `correlation_origin` field placement (AUDIT-WP-0017-T05); per-subject encrypted personal payload for signed privacyIDEA rows; whether to backfill privacyIDEA history; longer retention for privacyIDEA | audit-core | | P7 | KeyCape sends `traceparent` on its back-channel calls to Authelia and privacyIDEA | key-cape | ## 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.