Skip to content

Commit add215a

Browse files
Cherry-pick for release 6.18.1 (#5591)
* Add improvements to authorization settings * Add ecs.version to the audit log ECS documents (#5584) The authorization and message-action audit documents omitted the ecs.version field. ECS-consuming pipelines (Elasticsearch's built-in logs-*-* index template, Elastic Security, other SIEMs) use ecs.version to select the matching field mappings, so a conformant document should declare it. Both streams now emit "ecs": { "version": "8.11.0" } — the latest ECS schema release, whose event.category/type/outcome and user.* fields are what these documents already use. Additive, non-breaking output change. First shipped in 6.18.0 without the field, so this targets the next patch (6.18.1) or minor (6.19.0). ECS version field: https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html event.type allowed values (allowed/denied/change/deletion): https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-type.html ECS releases: https://github.com/elastic/ecs/releases * 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. * Add improvements to authorization settings (#5588) --------- Co-authored-by: Ramon Smits <ramon.smits@gmail.com>
1 parent d94e664 commit add215a

11 files changed

Lines changed: 80 additions & 9 deletions

File tree

src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ public static async Task AssertAuthConfigurationResponse(
105105
string expectedClientId = null,
106106
string expectedAuthority = null,
107107
string expectedAudience = null,
108-
string expectedApiScopes = null)
108+
string expectedApiScopes = null,
109+
bool expectedRoleBasedAuthorizationEnabled = false)
109110
{
110111
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
111112
"Authentication configuration endpoint should return 200 OK");
@@ -120,6 +121,11 @@ public static async Task AssertAuthConfigurationResponse(
120121
"Response should contain 'enabled' property");
121122
Assert.That(enabledProperty.GetBoolean(), Is.EqualTo(expectedEnabled),
122123
$"'enabled' should be {expectedEnabled}");
124+
125+
Assert.That(root.TryGetProperty("role_based_authorization_enabled", out var roleBasedAuthorizationEnabledProperty), Is.True,
126+
"Response should contain 'role_based_authorization_enabled' property");
127+
Assert.That(roleBasedAuthorizationEnabledProperty.GetBoolean(), Is.EqualTo(expectedRoleBasedAuthorizationEnabled),
128+
$"'role_based_authorization_enabled' should be {expectedRoleBasedAuthorizationEnabled}");
123129
}
124130

125131
// Note: API uses snake_case JSON serialization (client_id, api_scopes)

src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
7474
expectedEnabled: true,
7575
expectedClientId: TestClientId,
7676
expectedAudience: TestAudience,
77-
expectedApiScopes: TestApiScopes);
77+
expectedApiScopes: TestApiScopes,
78+
expectedRoleBasedAuthorizationEnabled: true);
7879
}
7980

8081
[Test]

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: 29 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;
@@ -27,6 +28,7 @@ public void Decision_allow_emits_one_entry_on_audit_category()
2728
Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub-001"));
2829
Assert.That(ecs.GetProperty("user").GetProperty("name").GetString(), Is.EqualTo("Alice Smith"));
2930
Assert.That(ecs.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:messages:retry"));
31+
Assert.That(ecs.GetProperty("ecs").GetProperty("version").GetString(), Is.EqualTo("8.11.0"));
3032
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information));
3133
}
3234

@@ -49,6 +51,33 @@ public void Decision_deny_emits_one_entry_on_audit_category()
4951
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning));
5052
}
5153

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+
5281
[Test]
5382
public void Decision_does_not_appear_on_other_categories()
5483
{

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public void Operation_emits_one_entry_on_operation_category()
4141
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("group-1"));
4242
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("count").GetInt32(), Is.EqualTo(42));
4343
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-1"));
44+
Assert.That(ecs.GetProperty("ecs").GetProperty("version").GetString(), Is.EqualTo("8.11.0"));
4445
}
4546

4647
[Test]

src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ public sealed class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAutho
1616
{
1717
public const string AuditCategory = "ServiceControl.Audit"; // Logger name is used in logging configuration to write audit entries to a separate file.
1818

19+
// The ECS version the emitted documents conform to, surfaced as the ecs.version field so downstream
20+
// pipelines can pick the matching mappings. 8.11.0 is the latest ECS schema release; the fields used
21+
// here (event.category/type/outcome, user.*) are stable across the 8.x line. Shared with
22+
// MessageActionAuditLog so both streams declare the same version.
23+
internal const string EcsVersion = "8.11.0";
24+
1925
readonly ILogger logger = loggerFactory.CreateLogger(AuditCategory);
2026

2127
// Relaxed escaping keeps the JSON readable for log sinks (no \uXXXX for '+', '<', accented names, …);
2228
// the HTML-safe default only matters in a browser context, which an audit log is not. Shared with
2329
// MessageActionAuditLog so both ECS streams keep the same serialization contract.
2430
internal static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
2531

26-
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)
2733
{
2834
ArgumentException.ThrowIfNullOrEmpty(subjectId);
2935
ArgumentException.ThrowIfNullOrEmpty(subjectName);
@@ -36,19 +42,20 @@ public void Decision(string subjectId, string subjectName, string permission, st
3642
return;
3743
}
3844

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

4349
// Serialises one authorization decision as an Elastic Common Schema (ECS) document so it ingests into
4450
// Elastic/Kibana — and most SIEMs — with no custom mapping. The schema is owned here, in the domain,
4551
// rather than in logging configuration. event.type/outcome carry the allow/deny; servicecontrol.* is the
4652
// app-specific namespace ECS reserves for custom fields.
47-
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)
4854
{
4955
var ecs = new Dictionary<string, object?>
5056
{
5157
["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
58+
["ecs"] = new { version = EcsVersion },
5259
["event"] = new
5360
{
5461
kind = "event",
@@ -60,7 +67,9 @@ static string BuildEcsEvent(string subjectId, string subjectName, string permiss
6067
["user"] = new
6168
{
6269
id = subjectId,
63-
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
6473
},
6574
["servicecontrol"] = new
6675
{

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
}

src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permi
6666
var ecs = new Dictionary<string, object?>
6767
{
6868
["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
69+
["ecs"] = new { version = AuthorizationAuditLog.EcsVersion },
6970
["event"] = new
7071
{
7172
kind = "event",

src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
154154

155155
void Validate(bool requireServicePulseSettings)
156156
{
157+
if (!Enabled && RoleBasedAuthorizationEnabled)
158+
{
159+
var message = "Authentication.RoleBasedAuthorizationEnabled cannot be true when Authentication.Enabled is false. Role-based authorization requires authentication to be enabled.";
160+
logger.LogCritical(message);
161+
throw new Exception(message);
162+
}
163+
157164
if (Enabled)
158165
{
159166
ValidateRequiredSettings(requireServicePulseSettings);

src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public void TearDown()
2929
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME", null);
3030
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY", null);
3131
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", null);
32+
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null);
3233
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", null);
3334
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", null);
3435
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", null);
@@ -49,6 +50,7 @@ public void Should_have_correct_defaults()
4950
Assert.That(settings.ValidateLifetime, Is.True);
5051
Assert.That(settings.ValidateIssuerSigningKey, Is.True);
5152
Assert.That(settings.RequireHttpsMetadata, Is.True);
53+
Assert.That(settings.RoleBasedAuthorizationEnabled, Is.False);
5254
Assert.That(settings.ServicePulseClientId, Is.Null);
5355
Assert.That(settings.ServicePulseApiScopes, Is.Null);
5456
Assert.That(settings.ServicePulseAuthority, Is.Null);
@@ -265,6 +267,16 @@ public void Should_succeed_without_service_pulse_settings_when_not_required()
265267
}
266268
}
267269

270+
[Test]
271+
public void Should_throw_when_role_based_authorization_enabled_without_authentication_enabled()
272+
{
273+
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true");
274+
275+
var ex = Assert.Throws<Exception>(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));
276+
277+
Assert.That(ex.Message, Does.Contain("RoleBasedAuthorizationEnabled cannot be true when Authentication.Enabled is false"));
278+
}
279+
268280
[Test]
269281
public void Should_not_validate_when_disabled()
270282
{

0 commit comments

Comments
 (0)