net-kingdom/docs/audit-core-intake-identity-providers.md
tegwick ccd0b15ee0 NK-WP-0055-T11: audit-core intake packages for Authelia, LLDAP, privacyIDEA (draft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4515@bnt-lap001
Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
2026-10-27 23:30:14 +01:00

26 KiB

title status owner work answers contract created
audit-core intake packages: Authelia, LLDAP, privacyIDEA draft net-kingdom NK-WP-0055-T11 audit-core AUDIT-WP-0017-T04 (three of the four identity providers; KeyCape is separate) canon/standards/audit-correlation_v0.1.md (accepted) 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:<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 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.