Skip to content

Audit log for authorization decisions#5520

Open
ramonsmits wants to merge 12 commits into
authfrom
authorizationauditlog
Open

Audit log for authorization decisions#5520
ramonsmits wants to merge 12 commits into
authfrom
authorizationauditlog

Conversation

@ramonsmits

@ramonsmits ramonsmits commented Jun 5, 2026

Copy link
Copy Markdown
Member

Audit trail for authorization decisions, emitted as Elastic Common Schema (ECS) JSON for SIEM ingestion.

Adds

  • IAuthorizationAuditLog / AuthorizationAuditLog (Infrastructure.Auth) — one allow/deny entry per evaluation, serialized as an ECS document (@timestamp, event.*, user.*, and the app-specific servicecontrol.* namespace). Allow → Information, deny → Warning.
  • PermissionVerbHandler emits an entry per check (subject id, display name, permission, resource, reason); subject claims are configurable.
  • Dedicated NLog routing: the ServiceControl.Audit category goes to its own target, Final so it never mixes with the plain-text operational log. The schema is owned in the domain class; the target writes the pre-rendered ECS document verbatim. Captured from Info upward independent of the operational LogLevel, so lowering verbosity never drops audit entries.

Sink behavior

  • Container (DOTNET_RUNNING_IN_CONTAINER=true): JSON to stdout (runtime log driver ships it). No file.
  • Service / binary install: rolling audit.json file (ship with Filebeat/Vector/etc.).

Sample output (one ECS JSON object per line)

{"@timestamp":"2026-06-15T08:19:46.2024106+00:00","event":{"kind":"event","category":["iam"],"type":["allowed"],"action":"error:messages:retry","outcome":"success"},"user":{"id":"alice-sub-001","name":"Alice Smith"},"servicecontrol":{"permission":"error:messages:retry","resource":"acme.sales","reason":"role:sc-operator matched"}}
{"@timestamp":"2026-06-15T08:19:46.2280169+00:00","event":{"kind":"event","category":["iam"],"type":["denied"],"action":"error:messages:retry","outcome":"failure"},"user":{"id":"bob-sub-002","name":"Bob Jones"},"servicecontrol":{"permission":"error:messages:retry","resource":null,"reason":"no matching role"}}

Tests

Unit tests for the audit log, the JSON routing, and an end-to-end render asserted as ECS with System.Text.Json (+ RecordingLoggerProvider helper).

Scope / follow-ups

Verb-level decisions only (resource is null today; populated once resource-scoped handlers land). user.roles / user.email, a RavenDB UI projection, and action/Tier-1 auditing are separate follow-ups.

PermissionVerbHandler now resolves the calling principal's subject id
(sub claim), display name (preferred_username, falling back to name then
sub), and roles, and emits a structured "allow" or "deny" entry through
the new IAuthorizationAuditLog for every verb-level check. Both
outcomes are captured — denies alone are insufficient for most
compliance use cases — and the reason embeds the matched role(s) for
allow and the candidate role(s) for deny.

AuthorizationAuditLog writes on the stable category "ServiceControl.Audit"
via a source-generated structured log method so any ILogger-compatible
sink (Seq, OTLP, file, in-memory test double, …) can collect or filter
the trail without coupling to the concrete type name. The audit log is
registered alongside the verb handler — only when OIDC is enabled and
the handler has decisions to make.

Unauthenticated requests are skipped at the top of HandleRequirementAsync
so the audit log only records identified principals; the framework
challenges with 401 via the policy's RequireAuthenticatedUser anyway.

Ported from the keycloak-rbac-poc spike (with the namespace flattened
from Infrastructure.Auth.Rbac to Infrastructure.Auth to match the real
branch) along with a RecordingLoggerProvider test helper colocated with
the unit tests.
ramonsmits added 10 commits June 5, 2026 16:31
PermissionVerbHandler now reads the subject id and subject name from
configurable claim keys (Authentication.SubjectIdClaim, default "sub";
Authentication.SubjectNameClaim, default "preferred_username") and
throws InvalidOperationException when an authenticated principal lacks
either claim or carries an empty value — both are required for the
audit log to be meaningful and a missing value indicates an IdP
misconfiguration the operator needs to fix.

The settings are passed through AddServiceControlAuthorization, which
now takes the full OpenIdConnectSettings (the existing bool-only
overload is removed; the six callers — three RunCommand entry points
and three acceptance-test runners — pass the settings object). The
MockOidcServer test helper defaults preferred_username to the subject
value so existing OIDC acceptance tests don't have to repeat the
boilerplate.
# Conflicts:
#	src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
#	src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs
#	src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
#	src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
#	src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
#	src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
Add a dedicated NLog target and a final logging rule that emit the authorization
audit trail as structured JSON on the ServiceControl.Audit category, separate
from the plain-text operational log, so it can be shipped to a SIEM without the
two streams polluting each other.

Audit events are captured from Info upward (allow = Information, deny = Warning)
independent of the operational LogLevel, so lowering operational verbosity never
drops entries from the audit trail. The audit rule is registered before the
catch-all operational rules and marked Final so audit events are not duplicated
into the operational log.

Extracts BuildConfiguration from ConfigureNLog, registers the targets explicitly
so the configuration is fully formed before it is installed, and exposes
AuthorizationAuditLog.AuditCategory so the routing is unit-testable against a
single source of truth for the category name. Tests assert the routing structure
and that a real decision renders as one valid JSON object per line.
The audit log message templates were changed to a capitalised "Allow:"/"Deny:"
prefix (and deny moved to Warning) when the allow/deny templates were split, but
these tests still asserted the old lowercase "allow"/"deny" substrings and an
Information level for deny — so they failed on CI (Linux-Default, Windows-Default).

Update the assertions to match the current output: "Allow:"/"Deny:" and deny at
Warning level.
…) document

AuthorizationAuditLog now serialises each decision as an ECS-shaped JSON document
(@timestamp, event.kind/category/type/action/outcome, user.id/name, and the
app-specific servicecontrol.* namespace) so it ingests into Elastic/Kibana — and
most SIEMs — with no custom mapping. The schema is owned in the domain class; the
NLog audit target now writes the pre-rendered document verbatim (one object per
line) instead of assembling JSON in logging configuration.

Allow/deny is carried by event.type (["allowed"]/["denied"]) and event.outcome
(success/failure); the log level still differs (Information/Warning) so sinks can
alert on denies without parsing the payload. Relaxed JSON escaping keeps the
output readable for log sinks.

Only fields available at the verb-level check are populated today; user.roles,
user.email and resource scope follow as that data reaches the decision point.
@ramonsmits ramonsmits marked this pull request as ready for review June 15, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant