Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ public static async Task AssertAuthConfigurationResponse(
string expectedClientId = null,
string expectedAuthority = null,
string expectedAudience = null,
string expectedApiScopes = null)
string expectedApiScopes = null,
bool expectedRoleBasedAuthorizationEnabled = false)
{
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
"Authentication configuration endpoint should return 200 OK");
Expand All @@ -120,6 +121,11 @@ public static async Task AssertAuthConfigurationResponse(
"Response should contain 'enabled' property");
Assert.That(enabledProperty.GetBoolean(), Is.EqualTo(expectedEnabled),
$"'enabled' should be {expectedEnabled}");

Assert.That(root.TryGetProperty("role_based_authorization_enabled", out var roleBasedAuthorizationEnabledProperty), Is.True,
"Response should contain 'role_based_authorization_enabled' property");
Assert.That(roleBasedAuthorizationEnabledProperty.GetBoolean(), Is.EqualTo(expectedRoleBasedAuthorizationEnabled),
$"'role_based_authorization_enabled' should be {expectedRoleBasedAuthorizationEnabled}");
}

// Note: API uses snake_case JSON serialization (client_id, api_scopes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
expectedEnabled: true,
expectedClientId: TestClientId,
expectedAudience: TestAudience,
expectedApiScopes: TestApiScopes);
expectedApiScopes: TestApiScopes,
expectedRoleBasedAuthorizationEnabled: true);
}

[Test]
Expand Down
4 changes: 2 additions & 2 deletions src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected override Task HandleRequirementAsync(
allowed: true,
reason: roles.Length == 0
? $"User holds '{permission}'"
: $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]");
: $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]", roles: roles);

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

// Leave the requirement unmet → the framework forbids (403).
return Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace ServiceControl.Infrastructure.Tests.Auth;

using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
Expand All @@ -27,6 +28,7 @@ public void Decision_allow_emits_one_entry_on_audit_category()
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(ecs.GetProperty("ecs").GetProperty("version").GetString(), Is.EqualTo("8.11.0"));
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information));
}

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

[Test]
public void Decision_includes_user_roles_when_supplied()
{
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", null, allowed: true, reason: "granted", roles: ["writer", "reader"]);

var user = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("user");
var roles = user.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).ToArray();
Assert.That(roles, Is.EqualTo(new[] { "writer", "reader" }));
}

[Test]
public void Decision_omits_user_roles_when_none()
{
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 roles", roles: []);

var user = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("user");
Assert.That(user.TryGetProperty("roles", out _), Is.False, "empty roles should be omitted");
}

[Test]
public void Decision_does_not_appear_on_other_categories()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public void Operation_emits_one_entry_on_operation_category()
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("group-1"));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("count").GetInt32(), Is.EqualTo(42));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-1"));
Assert.That(ecs.GetProperty("ecs").GetProperty("version").GetString(), Is.EqualTo("8.11.0"));
}

[Test]
Expand Down
17 changes: 13 additions & 4 deletions src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ public sealed class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAutho
{
public const string AuditCategory = "ServiceControl.Audit"; // Logger name is used in logging configuration to write audit entries to a separate file.

// The ECS version the emitted documents conform to, surfaced as the ecs.version field so downstream
// pipelines can pick the matching mappings. 8.11.0 is the latest ECS schema release; the fields used
// here (event.category/type/outcome, user.*) are stable across the 8.x line. Shared with
// MessageActionAuditLog so both streams declare the same version.
internal const string EcsVersion = "8.11.0";

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. Shared with
// MessageActionAuditLog so both ECS streams keep the same serialization contract.
internal static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };

public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason)
public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles = null)
{
ArgumentException.ThrowIfNullOrEmpty(subjectId);
ArgumentException.ThrowIfNullOrEmpty(subjectName);
Expand All @@ -36,19 +42,20 @@ public void Decision(string subjectId, string subjectName, string permission, st
return;
}

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

// 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)
static string BuildEcsEvent(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles)
{
var ecs = new Dictionary<string, object?>
{
["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
["ecs"] = new { version = EcsVersion },
["event"] = new
{
kind = "event",
Expand All @@ -60,7 +67,9 @@ static string BuildEcsEvent(string subjectId, string subjectName, string permiss
["user"] = new
{
id = subjectId,
name = subjectName
name = subjectName,
// Omitted (WhenWritingNull) when the principal has no roles, e.g. a denied request.
roles = roles is { Count: > 0 } ? roles : null
},
["servicecontrol"] = new
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System.Collections.Generic;

/// <summary>
/// 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 —
Expand All @@ -21,5 +23,6 @@ public interface IAuthorizationAuditLog
/// <param name="resource">The specific resource checked, or <see langword="null"/> for verb-level checks.</param>
/// <param name="allowed"><see langword="true"/> if the decision was allow; <see langword="false"/> for deny.</param>
/// <param name="reason">A human-readable explanation (e.g. which role granted the permission, or why nothing matched).</param>
void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason);
/// <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>
void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason, IReadOnlyCollection<string>? roles = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permi
var ecs = new Dictionary<string, object?>
{
["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
["ecs"] = new { version = AuthorizationAuditLog.EcsVersion },
["event"] = new
{
kind = "event",
Expand Down
7 changes: 7 additions & 0 deletions src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC

void Validate(bool requireServicePulseSettings)
{
if (!Enabled && RoleBasedAuthorizationEnabled)
{
var message = "Authentication.RoleBasedAuthorizationEnabled cannot be true when Authentication.Enabled is false. Role-based authorization requires authentication to be enabled.";
logger.LogCritical(message);
throw new Exception(message);
}

if (Enabled)
{
ValidateRequiredSettings(requireServicePulseSettings);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void TearDown()
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", null);
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", null);
Expand All @@ -49,6 +50,7 @@ public void Should_have_correct_defaults()
Assert.That(settings.ValidateLifetime, Is.True);
Assert.That(settings.ValidateIssuerSigningKey, Is.True);
Assert.That(settings.RequireHttpsMetadata, Is.True);
Assert.That(settings.RoleBasedAuthorizationEnabled, Is.False);
Assert.That(settings.ServicePulseClientId, Is.Null);
Assert.That(settings.ServicePulseApiScopes, Is.Null);
Assert.That(settings.ServicePulseAuthority, Is.Null);
Expand Down Expand Up @@ -265,6 +267,16 @@ public void Should_succeed_without_service_pulse_settings_when_not_required()
}
}

[Test]
public void Should_throw_when_role_based_authorization_enabled_without_authentication_enabled()
{
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true");

var ex = Assert.Throws<Exception>(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));

Assert.That(ex.Message, Does.Contain("RoleBasedAuthorizationEnabled cannot be true when Authentication.Enabled is false"));
}

[Test]
public void Should_not_validate_when_disabled()
{
Expand Down
2 changes: 2 additions & 0 deletions src/ServiceControl/Authentication/AuthenticationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public ActionResult<AuthConfig> Configuration()
var info = new AuthConfig
{
Enabled = settings.OpenIdConnectSettings.Enabled,
RoleBasedAuthorizationEnabled = settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled,
ClientId = settings.OpenIdConnectSettings.ServicePulseClientId,
Authority = settings.OpenIdConnectSettings.ServicePulseAuthority,
Audience = settings.OpenIdConnectSettings.Audience,
Expand All @@ -34,6 +35,7 @@ public ActionResult<AuthConfig> Configuration()
public class AuthConfig
{
public bool Enabled { get; set; }
public bool RoleBasedAuthorizationEnabled { get; set; }
public string ClientId { get; set; }
public string Authority { get; set; }
public string Audience { get; set; }
Expand Down
Loading