diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs index 03f5c156eb..81322cf47c 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs @@ -184,9 +184,13 @@ public string GenerateToken( { var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); + // sub + preferred_username are required by PermissionVerbHandler for the audit log; + // defaulting them here keeps callers concise. Callers that need to test the + // missing-claim path pass an explicit additionalClaim with an empty value to override. var claims = new List { new(JwtRegisteredClaimNames.Sub, subject), + new("preferred_username", subject), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index c73fd28a46..4a1b91b263 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index eba714e32d..08c4db3cf3 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using ServiceControl.Infrastructure; @@ -21,6 +22,11 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder return; } + // Shared with the authorization services and the claims transformation below; registered + // once so it can be constructor-injected rather than captured. TryAdd keeps it idempotent + // with AddServiceControlAuthorization, which registers the same instance. + hostBuilder.Services.TryAddSingleton(oidcSettings); + _ = hostBuilder.Services.AddAuthentication(options => { options.DefaultScheme = "Bearer"; @@ -104,9 +110,8 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder // Normalise per-IdP role claim shapes (Keycloak's nested realm_access.roles, Entra app // roles, Cognito groups) into canonical "roles" claims for the verb handler. The source - // path is configurable via Authentication.RolesClaim. - hostBuilder.Services.AddSingleton( - new RolesClaimsTransformation(oidcSettings.RolesClaim)); + // path is configurable via Authentication.RolesClaim, read off the injected settings. + hostBuilder.Services.AddSingleton(); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 82d6c3fcbc..6e56aa6e89 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -3,9 +3,10 @@ namespace ServiceControl.Hosting.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; /// /// Registers the permission-based policy authorization services: a dynamic @@ -25,6 +26,10 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h { var services = hostBuilder.Services; + // The settings are shared by every auth service below (and the authentication wiring), so they + // are registered once in DI and constructor-injected rather than captured in factory lambdas. + services.TryAddSingleton(oidcSettings); + // Ensure the authorization core services and options are present (idempotent). services.AddAuthorization(); @@ -34,9 +39,15 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // request to an annotated endpoint. When RBAC is disabled the provider returns allow-all // policies (no requirement), so anonymous-to-the-policy calls pass through and the verb // handler is unnecessary. - services.AddSingleton(sp => - new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings)); + services.AddSingleton(); - services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); + // The provider only emits a PermissionRequirement when RBAC is enabled, so the handler is the + // only thing that evaluates one. It is registered alongside the provider (cheap singleton, never + // invoked when no requirement is produced). The handler emits an audit-log entry for every + // decision through IAuthorizationAuditLog so the platform can show, after the fact, who attempted + // what and how the system responded. The subject-id and subject-name claim names are read off the + // injected OpenIdConnectSettings so the handler can match them on the principal. + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 68a56e29ba..e564cf4635 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -1,42 +1,90 @@ #nullable enable namespace ServiceControl.Hosting.Auth; +using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// /// Verb-level authorization handler for . It resolves the user's /// roles and checks them against the hardcoded policy: the user must hold -/// a role (e.g. reader / writer) that grants the requested permission. +/// a role (e.g. reader / writer) that grants the requested permission. Every decision is +/// captured through for compliance. /// /// Only registered — and only reached — when OIDC is enabled. When it is disabled, /// returns an allow-all policy that carries no /// , so this handler is not needed. /// /// -public sealed class PermissionVerbHandler : AuthorizationHandler +public sealed class PermissionVerbHandler( + IAuthorizationAuditLog auditLog, + OpenIdConnectSettings oidcSettings) + : AuthorizationHandler { - public PermissionVerbHandler(string rolesClaimName) - { - RoleClaimType = rolesClaimName; - } + // The per-IdP variability of the source claim is absorbed by RolesClaimsTransformation, which + // reads from the path configured in Authentication.RolesClaim and emits canonical "roles" claims. + const string RoleClaimType = "roles"; protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { - var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); + // Unauthenticated requests have no subject and no roles. The framework will challenge with + // 401 because the policy also includes RequireAuthenticatedUser; skipping here keeps the + // audit log restricted to identified principals. + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + var subjectId = RequireClaim(context.User, oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); + var subjectName = RequireClaim(context.User, oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); + var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value).ToArray(); + var permission = requirement.Permission; - if (RolePermissions.IsGranted(roles, requirement.Permission)) + if (RolePermissions.IsGranted(roles, permission)) { + auditLog.Decision( + subjectId, + subjectName, + permission, + resource: null, + allowed: true, + reason: roles.Length == 0 + ? $"User holds '{permission}'" + : $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]"); + context.Succeed(requirement); + return Task.CompletedTask; } - // Otherwise leave the requirement unmet → the request is denied (403/401). + auditLog.Decision( + subjectId, + subjectName, + permission, + resource: null, + allowed: false, + reason: roles.Length == 0 + ? $"User has no roles granting '{permission}'" + : $"None of the user's role(s) [{string.Join(", ", roles)}] grants '{permission}'"); + + // Leave the requirement unmet → the framework forbids (403). return Task.CompletedTask; } - internal string RoleClaimType = "roles"; -} \ No newline at end of file + static string RequireClaim(ClaimsPrincipal user, string claimType, string settingName) + { + var value = user.FindFirst(claimType)?.Value; + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException( + $"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " + + "Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits."); + } + return value; + } +} diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs index 2f59829edc..ad14ef1228 100644 --- a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Hosting.Auth; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// @@ -18,7 +19,7 @@ namespace ServiceControl.Hosting.Auth; /// claim makes the transformation idempotent and returns the same principal on subsequent calls. /// /// -public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation +public sealed class RolesClaimsTransformation(OpenIdConnectSettings oidcSettings) : IClaimsTransformation { const string SentinelClaimType = "_roles_transformed"; // The sentinel's value is irrelevant; only the claim's presence matters. A non-empty @@ -34,7 +35,7 @@ public Task TransformAsync(ClaimsPrincipal principal) return Task.FromResult(principal); } - var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); + var roles = RolesClaimExtractor.Extract(principal, oidcSettings.RolesClaim); var claims = new Claim[roles.Count + 1]; claims[0] = new Claim(SentinelClaimType, SentinelClaimValue); diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs new file mode 100644 index 0000000000..69213d9887 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs @@ -0,0 +1,98 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class AuthorizationAuditLogTests +{ + [Test] + public void Decision_allow_emits_one_entry_on_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice-sub-001", "Alice Smith", "error:messages:retry", "acme.sales", allowed: true, reason: "role:reader matched"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success")); + Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub-001")); + Assert.That(ecs.GetProperty("user").GetProperty("name").GetString(), Is.EqualTo("Alice Smith")); + Assert.That(ecs.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:messages:retry")); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information)); + } + + [Test] + public void Decision_deny_emits_one_entry_on_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("bob-sub-002", "Bob Jones", "error:messages:retry", null, allowed: false, reason: "no matching role"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); + Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); + Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002")); + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning)); + } + + [Test] + public void Decision_does_not_appear_on_other_categories() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("carol-sub-003", "Carol White", "error:endpoints:view", null, allowed: true, reason: "role:reader matched"); + + Assert.That(provider.EntriesFor("ServiceControl.SomeOtherCategory"), Is.Empty); + } + + [Test] + public void Multiple_decisions_accumulate_in_order() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice-sub-001", "alice", "error:messages:view", null, allowed: true, "role matched"); + auditLog.Decision("alice-sub-001", "alice", "error:messages:retry", "acme.finance", allowed: false, "out of scope"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(2)); + Assert.That(JsonDocument.Parse(entries[0].Message).RootElement.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(JsonDocument.Parse(entries[1].Message).RootElement.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); + } + + [TestCase(null, "Alice", "error:messages:retry", "reason")] + [TestCase("", "Alice", "error:messages:retry", "reason")] + [TestCase("alice-sub-001", null, "error:messages:retry", "reason")] + [TestCase("alice-sub-001", "", "error:messages:retry", "reason")] + [TestCase("alice-sub-001", "Alice", null, "reason")] + [TestCase("alice-sub-001", "Alice", "", "reason")] + [TestCase("alice-sub-001", "Alice", "error:messages:retry", null)] + [TestCase("alice-sub-001", "Alice", "error:messages:retry", "")] + public void Decision_throws_when_required_argument_is_null_or_empty(string? subjectId, string? subjectName, string? permission, string? reason) + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + Assert.That( + () => auditLog.Decision(subjectId!, subjectName!, permission!, resource: null, allowed: true, reason: reason!), + Throws.InstanceOf()); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs new file mode 100644 index 0000000000..a753a2b8e4 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs @@ -0,0 +1,59 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +/// +/// In-memory that captures log entries for test assertions. +/// Thread-safe. Use for all captured entries; +/// to filter by category. +/// +sealed class RecordingLoggerProvider : ILoggerProvider +{ + readonly ConcurrentQueue entries = new(); + + public IReadOnlyList Entries => entries.ToArray(); + + public IReadOnlyList EntriesFor(string category) => + entries.Where(e => e.Category == category).ToArray(); + + public ILogger CreateLogger(string categoryName) => + new RecordingLogger(categoryName, entries); + + public void Dispose() { } +} + +sealed record LogEntry( + string Category, + LogLevel Level, + EventId EventId, + string Message, + Exception? Exception); + +sealed class RecordingLogger(string category, ConcurrentQueue sink) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + sink.Enqueue(new LogEntry(category, logLevel, eventId, message, exception)); + } + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs new file mode 100644 index 0000000000..cbc790d9de --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs @@ -0,0 +1,108 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests; + +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Config; +using NLog.Extensions.Logging; +using NLog.Targets; +using NUnit.Framework; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; +using LogLevel = NLog.LogLevel; + +[TestFixture] +public class LoggingConfiguratorTests +{ + static readonly string AuditPattern = $"{AuthorizationAuditLog.AuditCategory}*"; + + static LoggingConfiguration BuildConfig() => + LoggingConfigurator.BuildConfiguration("logfile.txt", Path.GetTempPath(), LogLevel.Info); + + [Test] + public void Audit_target_emits_the_prerendered_event_verbatim() + { + var auditTarget = BuildConfig().LoggingRules + .Single(r => r.LoggerNamePattern == AuditPattern) + .Targets.OfType() + .Single(t => t.Name == "audit-console"); + + var rendered = auditTarget.Layout.Render(new LogEventInfo(LogLevel.Info, AuthorizationAuditLog.AuditCategory, "ECS-PAYLOAD")); + + Assert.That(rendered, Is.EqualTo("ECS-PAYLOAD"), + "the audit target must pass the pre-rendered ECS JSON through unwrapped, not double-encode it"); + } + + [Test] + public void Audit_events_do_not_fall_through_to_the_operational_log() + { + var config = BuildConfig(); + + var auditRule = config.LoggingRules.Single(r => r.LoggerNamePattern == AuditPattern); + var operationalConsoleRule = config.LoggingRules.Single(r => r.LoggerNamePattern == "*" && r.Targets.Any(t => t.Name == "console")); + + Assert.That(auditRule.Final, Is.True, "the audit rule must be final so audit JSON is not duplicated into the plain-text operational log"); + Assert.That( + config.LoggingRules.IndexOf(auditRule), + Is.LessThan(config.LoggingRules.IndexOf(operationalConsoleRule)), + "the audit rule must be evaluated before the catch-all console rule for Final to take effect"); + } + + [Test] + public void Audit_decisions_render_as_valid_structured_json() + { + // Use the exact JSON layout the production configuration builds... + var auditLayout = BuildConfig().AllTargets + .OfType() + .Single(t => t.Name == "audit-console") + .Layout; + + // ...and capture what it renders, driven through the real audit logger over an isolated NLog factory. + var captured = new MemoryTarget("audit-capture") { Layout = auditLayout }; + var captureConfig = new LoggingConfiguration(); + captureConfig.AddRule(LogLevel.Info, LogLevel.Fatal, captured, AuditPattern); + var logFactory = new LogFactory { Configuration = captureConfig }; + + using (var loggerFactory = LoggerFactory.Create(b => b.AddNLog(_ => logFactory))) + { + var audit = new AuthorizationAuditLog(loggerFactory); + audit.Decision("alice-sub-001", "Alice Smith", "error:messages:retry", "acme.sales", allowed: true, reason: "role:sc-operator matched"); + audit.Decision("bob-sub-002", "Bob Jones", "error:messages:retry", null, allowed: false, reason: "no matching role"); + } + + logFactory.Flush(); + + Assert.That(captured.Logs, Has.Count.EqualTo(2), "expected one JSON line per decision"); + + foreach (var line in captured.Logs) + { + TestContext.Progress.WriteLine(line); + } + + var allow = JsonDocument.Parse(captured.Logs[0]).RootElement; + Assert.Multiple(() => + { + Assert.That(allow.GetProperty("@timestamp").GetString(), Is.Not.Empty, "ECS @timestamp should be present"); + Assert.That(allow.GetProperty("event").GetProperty("kind").GetString(), Is.EqualTo("event")); + Assert.That(allow.GetProperty("event").GetProperty("category")[0].GetString(), Is.EqualTo("iam")); + Assert.That(allow.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(allow.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:messages:retry")); + Assert.That(allow.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success")); + Assert.That(allow.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub-001")); + Assert.That(allow.GetProperty("user").GetProperty("name").GetString(), Is.EqualTo("Alice Smith")); + Assert.That(allow.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("acme.sales")); + }); + + var deny = JsonDocument.Parse(captured.Logs[1]).RootElement; + Assert.Multiple(() => + { + Assert.That(deny.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); + Assert.That(deny.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); + Assert.That(deny.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002")); + Assert.That(deny.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null), "absent resource should be JSON null"); + }); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs new file mode 100644 index 0000000000..75a15fb980 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -0,0 +1,83 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +/// +/// Default that emits every decision as a structured log entry on +/// the stable category ServiceControl.Audit. Sinks filter on the category, not on the type name. +/// +public sealed partial class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAuthorizationAuditLog +{ + public const string AuditCategory = "ServiceControl.Audit"; // Logger name is used in logging configuration to write audit entries to a separate file. + + readonly ILogger logger = loggerFactory.CreateLogger(AuditCategory); + + // Relaxed escaping keeps the JSON readable for log sinks (no \uXXXX for '+', '<', accented names, …); + // the HTML-safe default only matters in a browser context, which an audit log is not. + static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) + { + ArgumentException.ThrowIfNullOrEmpty(subjectId); + ArgumentException.ThrowIfNullOrEmpty(subjectName); + ArgumentException.ThrowIfNullOrEmpty(permission); + ArgumentException.ThrowIfNullOrEmpty(reason); + + var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason); + + if (allowed) + { + LogAllow(logger, auditEvent); + } + else + { + LogDeny(logger, auditEvent); + } + } + + // Serialises one authorization decision as an Elastic Common Schema (ECS) document so it ingests into + // Elastic/Kibana — and most SIEMs — with no custom mapping. The schema is owned here, in the domain, + // rather than in logging configuration. event.type/outcome carry the allow/deny; servicecontrol.* is the + // app-specific namespace ECS reserves for custom fields. + static string BuildEcsEvent(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) + { + var ecs = new Dictionary + { + ["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["event"] = new + { + kind = "event", + category = new[] { "iam" }, + type = new[] { allowed ? "allowed" : "denied" }, + action = permission, + outcome = allowed ? "success" : "failure" + }, + ["user"] = new + { + id = subjectId, + name = subjectName + }, + ["servicecontrol"] = new + { + permission, + resource, + reason + } + }; + + return JsonSerializer.Serialize(ecs, EcsJsonOptions); + } + + // Source-generated structured log methods — the audit event is the pre-rendered ECS JSON document. Allow + // and deny differ only by level so sinks can alert on denies (Warning) without parsing the payload. + [LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "{AuditEvent}")] + static partial void LogAllow(ILogger logger, string auditEvent); + + [LoggerMessage(EventId = 1002, Level = LogLevel.Warning, Message = "{AuditEvent}")] + static partial void LogDeny(ILogger logger, string auditEvent); +} diff --git a/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs new file mode 100644 index 0000000000..d6449673cc --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs @@ -0,0 +1,25 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// +/// Records every authorization allow/deny decision so the platform can demonstrate, after the fact, +/// who attempted what and how the system responded. Both allow and deny outcomes are captured — +/// denies alone are insufficient for most compliance use cases. +/// +/// Implementations write structured log entries on a stable category so sinks (Seq, OTLP, file, +/// in-memory test double, …) can filter on it without coupling to the concrete type name. +/// +/// +public interface IAuthorizationAuditLog +{ + /// + /// Records a single authorization decision. + /// + /// Stable identifier of the principal (e.g. the JWT sub claim). Must not be null or empty. + /// Human-readable display name of the principal (e.g. preferred_username). Must not be null or empty. + /// The permission that was evaluated (e.g. error:messages:retry). + /// The specific resource checked, or for verb-level checks. + /// if the decision was allow; for deny. + /// A human-readable explanation (e.g. which role granted the permission, or why nothing matched). + void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason); +} diff --git a/src/ServiceControl.Infrastructure/LoggingConfigurator.cs b/src/ServiceControl.Infrastructure/LoggingConfigurator.cs index b94fd8b296..6c0b090dea 100644 --- a/src/ServiceControl.Infrastructure/LoggingConfigurator.cs +++ b/src/ServiceControl.Infrastructure/LoggingConfigurator.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Infrastructure using NLog.Layouts; using NLog.Targets; using ServiceControl.Configuration; + using ServiceControl.Infrastructure.Auth; using LogManager = NServiceBus.Logging.LogManager; using LogLevel = NLog.LogLevel; @@ -28,6 +29,17 @@ public static void ConfigureLogging(LoggingSettings loggingSettings) } public static string ConfigureNLog(string logFileName, string logPath, LogLevel logLevel) + { + var nlogConfig = BuildConfiguration(logFileName, logPath, logLevel); + + NLog.LogManager.Configuration = nlogConfig; + + var logEventInfo = new LogEventInfo { TimeStamp = DateTime.UtcNow }; + var fileTarget = nlogConfig.FindTargetByName("file"); + return AppEnvironment.RunningInContainer ? "console" : fileTarget.FileName.Render(logEventInfo); + } + + public static LoggingConfiguration BuildConfiguration(string logFileName, string logPath, LogLevel logLevel) { //configure NLog var nlogConfig = new LoggingConfiguration(); @@ -65,20 +77,62 @@ public static string ConfigureNLog(string logFileName, string logPath, LogLevel FinalMinLevel = LogLevel.Warn }; + // The authorization audit trail is emitted on a dedicated category, separate from the plain-text + // operational log, so it can be shipped to a SIEM without the two streams polluting each other. + // Each event is already a complete ECS JSON document (built in AuthorizationAuditLog); the target + // writes it verbatim, one object per line. + var auditLayout = new SimpleLayout("${message}"); + + var auditConsoleTarget = new ConsoleTarget + { + Name = "audit-console", + Layout = auditLayout + }; + + var auditFileTarget = new FileTarget + { + Name = "audit-file", + ArchiveEvery = FileArchivePeriod.Day, + FileName = Path.Combine(logPath, "audit.json"), + ArchiveSuffixFormat = ".{1:yyyy-MM-dd}.{0:00}", + Layout = auditLayout, + MaxArchiveFiles = 14, + ArchiveAboveSize = 30 * megaByte + }; + + // Audit events are captured from Info upward (allow = Information, deny = Warning) regardless of the + // operational LogLevel — lowering the operational verbosity must never drop entries from the audit trail. + // Final stops audit events from also reaching the catch-all operational rules below, so this rule must + // be registered before them. + var auditRule = new LoggingRule + { + LoggerNamePattern = $"{AuthorizationAuditLog.AuditCategory}*", + Final = true + }; + auditRule.SetLoggingLevels(LogLevel.Info, LogLevel.Fatal); + auditRule.Targets.Add(auditConsoleTarget); + if (!AppEnvironment.RunningInContainer) + { + auditRule.Targets.Add(auditFileTarget); + } + + nlogConfig.AddTarget(consoleTarget); + nlogConfig.AddTarget(auditConsoleTarget); + nlogConfig.LoggingRules.Add(aspNetCoreRule); nlogConfig.LoggingRules.Add(httpClientRule); + nlogConfig.LoggingRules.Add(auditRule); nlogConfig.LoggingRules.Add(new LoggingRule("*", logLevel, consoleTarget)); if (!AppEnvironment.RunningInContainer) { + nlogConfig.AddTarget(fileTarget); + nlogConfig.AddTarget(auditFileTarget); nlogConfig.LoggingRules.Add(new LoggingRule("*", logLevel, fileTarget)); } - NLog.LogManager.Configuration = nlogConfig; - - var logEventInfo = new LogEventInfo { TimeStamp = DateTime.UtcNow }; - return AppEnvironment.RunningInContainer ? "console" : fileTarget.FileName.Render(logEventInfo); + return nlogConfig; } static LogLevel ToNLogLevel(this Microsoft.Extensions.Logging.LogLevel level) => diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 9c2d74158f..33f0e461a0 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -35,6 +35,13 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); + // Claims that identify the principal in the authorization audit log. The handler treats both + // as required — a missing or empty value is a sign that the IdP isn't emitting the expected + // claim and the operator needs to fix the configuration, so the handler will throw rather + // than substitute a placeholder. + SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", "sub"); + SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", "preferred_username"); + // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login if (requireServicePulseSettings) @@ -99,6 +106,20 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public bool RequireHttpsMetadata { get; } + /// + /// Claim that carries the stable subject identifier (e.g. the JWT sub claim) recorded in + /// the authorization audit log. Required — the handler throws if the configured claim is absent + /// or empty on an authenticated principal. + /// + public string SubjectIdClaim { get; } + + /// + /// Claim that carries the human-readable subject name (e.g. preferred_username) recorded + /// in the authorization audit log. Required — the handler throws if the configured claim is + /// absent or empty on an authenticated principal. + /// + public string SubjectNameClaim { get; } + /// /// Optional override for the authority URL that ServicePulse should use for authentication. /// If not specified, ServicePulse uses the main Authority value. @@ -205,8 +226,8 @@ void LogConfiguration(bool requireServicePulseSettings) var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)"; var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)"; - logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", - Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); + logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", + Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); // Warn about potential misconfigurations var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience); diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index fed5a2acf4..a35b4112e2 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 72e33e6946..6e7be8475f 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null,