Audit log for authorization decisions#5520
Open
ramonsmits wants to merge 12 commits into
Open
Conversation
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.
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.
…tance-specific categories
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-specificservicecontrol.*namespace). Allow →Information, deny →Warning.PermissionVerbHandleremits an entry per check (subject id, display name, permission, resource, reason); subject claims are configurable.ServiceControl.Auditcategory goes to its own target,Finalso 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 fromInfoupward independent of the operationalLogLevel, so lowering verbosity never drops audit entries.Sink behavior
DOTNET_RUNNING_IN_CONTAINER=true): JSON to stdout (runtime log driver ships it). No file.audit.jsonfile (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(+RecordingLoggerProviderhelper).Scope / follow-ups
Verb-level decisions only (resource is
nulltoday; populated once resource-scoped handlers land).user.roles/user.email, a RavenDB UI projection, and action/Tier-1 auditing are separate follow-ups.