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
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.authelianetkingdom.lldapnetkingdom.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
tenantto be a non-empty value. - Proposal: use the fixed value
platformfor every event, and give the sender credential onlyplatformas a permitted tenant. - [unknown] Is
platformthe 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.outcomeis one ofsuccess,failureorrefused.data.actoris 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
subjectorid. - [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_policyis 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_readis 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. Nolog.formatis 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.sqlite3on theautheliaPVC [repo]. - Each row records [upstream]:
- one first-factor or second-factor attempt, success or failure;
- username,
auth_type,remote_ip,request_uriandrequest_method; - a monotonic
id.
- Since 4.38, the
banned_userandbanned_iptables 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_logsand the ban tables byid, 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=roand never writes to it.
(b) The container stdout, with log.format: json:
- The sidecar reads it through a shared
emptyDirlog file. This needs a--log.file_pathsetting [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
traceparentrequest header kept. Header names only; neverCookieorAuthorization. - Join on time, router and client IP to recover propagated ids for OIDC back-channel calls from KeyCape:
/api/oidc/tokenand/api/oidc/userinfo. - Only server-to-server calls can carry an inbound
traceparent. - [unknown] Whether Traefik tracing or header logging is enabled on railiance01.
- Enable the Traefik access log in JSON with the
- Same login, two records: The OIDC
authorize→tokenexchange 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
traceparenton 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
tracinglog 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=truemust be verified against the pinned image. verbose=truelogs too much, including search filters that contain usernames.
- Which fields appear without
- 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
lldapcontainer'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.
- The repo's admin tools (
3.4 Correlation
- LDAP (3890): There are no headers, so
correlation_origin: generatedalways (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 logtraceparent[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 aparent_event_idlink 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,durationaction(e.g.POST /validate/check),success,action_detail,infoserial,token_typeuser,realm,resolver,administratorclient(IP),privacyidea_serverpolicies,container_serialsignature
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 ofprivacyidea|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.nativeholds every signed column, unchanged, plussignature.data.native_signature_algholds the privacyIDEA version and audit module.data.verification_key_fingerprintholds 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.nativewould 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,subjectandactorfields. - 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_invalidwith 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_audithas no column for it. -
Default:
correlation_origin: generated. -
Improvement: KeyCape's MFA call (
/validate/check) is server-to-server. If KeyCape sendstraceparentand 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
actionbefore 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
- 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.
- 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.
- Signed-string format. The privacyIDEA signed-string format and its stability across patch releases. Exporter-side verification depends on it.
- Ingress path and logging. Whether KeyCape reaches Authelia and privacyIDEA through Traefik or through cluster Services, and whether Traefik logs request headers on railiance01.
- Traffic split and volume. The real distribution per action and the human-versus-automation split. Every volume figure above is an estimate.
- Password reset. Whether Authelia password reset is enabled. The notifier is configured, but the reset policy is
not set explicitly in
configmap.yaml. - Deployed config. Whether the deployed configuration still matches the repo manifests. ADR-0015 migration and live drift are not checked in this draft.