Skip to content

Commit 5695500

Browse files
authored
Add user.roles to the authorization audit log (#5583)
Add user.roles to the authorization audit log Emit the roles the principal held as the ECS user.roles array on each authorization decision, so SIEMs can facet and alert on roles instead of parsing them out of the free-text reason. Omitted when the principal has no roles.
1 parent 6b01141 commit 5695500

4 files changed

Lines changed: 40 additions & 7 deletions

File tree

src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ protected override Task HandleRequirementAsync(
5252
allowed: true,
5353
reason: roles.Length == 0
5454
? $"User holds '{permission}'"
55-
: $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]");
55+
: $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]", roles: roles);
5656

5757
context.Succeed(requirement);
5858
return Task.CompletedTask;
@@ -66,7 +66,7 @@ protected override Task HandleRequirementAsync(
6666
allowed: false,
6767
reason: roles.Length == 0
6868
? $"User has no roles granting '{permission}'"
69-
: $"None of the user's role(s) [{string.Join(", ", roles)}] grants '{permission}'");
69+
: $"None of the user's role(s) [{string.Join(", ", roles)}] grants '{permission}'", roles: roles);
7070

7171
// Leave the requirement unmet → the framework forbids (403).
7272
return Task.CompletedTask;

src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
namespace ServiceControl.Infrastructure.Tests.Auth;
33

44
using System;
5+
using System.Linq;
56
using System.Text.Json;
67
using Microsoft.Extensions.Logging;
78
using NUnit.Framework;
@@ -50,6 +51,33 @@ public void Decision_deny_emits_one_entry_on_audit_category()
5051
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning));
5152
}
5253

54+
[Test]
55+
public void Decision_includes_user_roles_when_supplied()
56+
{
57+
var provider = new RecordingLoggerProvider();
58+
var factory = LoggerFactory.Create(b => b.AddProvider(provider));
59+
var auditLog = new AuthorizationAuditLog(factory);
60+
61+
auditLog.Decision("alice-sub-001", "Alice Smith", "error:messages:retry", null, allowed: true, reason: "granted", roles: ["writer", "reader"]);
62+
63+
var user = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("user");
64+
var roles = user.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).ToArray();
65+
Assert.That(roles, Is.EqualTo(new[] { "writer", "reader" }));
66+
}
67+
68+
[Test]
69+
public void Decision_omits_user_roles_when_none()
70+
{
71+
var provider = new RecordingLoggerProvider();
72+
var factory = LoggerFactory.Create(b => b.AddProvider(provider));
73+
var auditLog = new AuthorizationAuditLog(factory);
74+
75+
auditLog.Decision("bob-sub-002", "Bob Jones", "error:messages:retry", null, allowed: false, reason: "no roles", roles: []);
76+
77+
var user = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("user");
78+
Assert.That(user.TryGetProperty("roles", out _), Is.False, "empty roles should be omitted");
79+
}
80+
5381
[Test]
5482
public void Decision_does_not_appear_on_other_categories()
5583
{

src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public sealed class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAutho
2929
// MessageActionAuditLog so both ECS streams keep the same serialization contract.
3030
internal static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
3131

32-
public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason)
32+
public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles = null)
3333
{
3434
ArgumentException.ThrowIfNullOrEmpty(subjectId);
3535
ArgumentException.ThrowIfNullOrEmpty(subjectName);
@@ -42,15 +42,15 @@ public void Decision(string subjectId, string subjectName, string permission, st
4242
return;
4343
}
4444

45-
var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason);
45+
var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason, roles);
4646
logger.Log(level, allowed ? AllowEventId : DenyEventId, auditEvent, null, IdentityFormatter);
4747
}
4848

4949
// Serialises one authorization decision as an Elastic Common Schema (ECS) document so it ingests into
5050
// Elastic/Kibana — and most SIEMs — with no custom mapping. The schema is owned here, in the domain,
5151
// rather than in logging configuration. event.type/outcome carry the allow/deny; servicecontrol.* is the
5252
// app-specific namespace ECS reserves for custom fields.
53-
static string BuildEcsEvent(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason)
53+
static string BuildEcsEvent(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles)
5454
{
5555
var ecs = new Dictionary<string, object?>
5656
{
@@ -67,7 +67,9 @@ static string BuildEcsEvent(string subjectId, string subjectName, string permiss
6767
["user"] = new
6868
{
6969
id = subjectId,
70-
name = subjectName
70+
name = subjectName,
71+
// Omitted (WhenWritingNull) when the principal has no roles, e.g. a denied request.
72+
roles = roles is { Count: > 0 } ? roles : null
7173
},
7274
["servicecontrol"] = new
7375
{

src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#nullable enable
22
namespace ServiceControl.Infrastructure.Auth;
33

4+
using System.Collections.Generic;
5+
46
/// <summary>
57
/// Records every authorization allow/deny decision so the platform can demonstrate, after the fact,
68
/// who attempted what and how the system responded. Both allow and deny outcomes are captured —
@@ -21,5 +23,6 @@ public interface IAuthorizationAuditLog
2123
/// <param name="resource">The specific resource checked, or <see langword="null"/> for verb-level checks.</param>
2224
/// <param name="allowed"><see langword="true"/> if the decision was allow; <see langword="false"/> for deny.</param>
2325
/// <param name="reason">A human-readable explanation (e.g. which role granted the permission, or why nothing matched).</param>
24-
void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason);
26+
/// <param name="roles">The roles the principal held at decision time, emitted as the ECS <c>user.roles</c> array so SIEMs can facet on them. Omitted from the document when null or empty.</param>
27+
void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles = null);
2528
}

0 commit comments

Comments
 (0)