Skip to content
Draft
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 @@ -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<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new("preferred_username", subject),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async Task InitializeServiceControl(ScenarioContext context)
EnvironmentName = Environments.Development
});
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControl(settings, configuration);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlApi(settings.CorsSettings);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async Task InitializeServiceControl(ScenarioContext context)
EnvironmentName = Environments.Development
});
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) =>
{
var logitem = new ScenarioContext.LogItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"ValidateIssuerSigningKey": true,
"RequireHttpsMetadata": true,
"RolesClaim": "realm_access.roles",
"SubjectIdClaim": "sub",
"SubjectNameClaim": "preferred_username",
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public override async Task Execute(HostArguments args, Settings settings)
var hostBuilder = WebApplication.CreateBuilder();

hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAudit((_, __) =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace ServiceControl.Hosting.Auth;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ServiceControl.Infrastructure;
using ServiceControl.Infrastructure.Auth;

/// <summary>
/// Registers the permission-based policy authorization services: a dynamic
Expand All @@ -20,7 +22,7 @@ namespace ServiceControl.Hosting.Auth;
/// </summary>
public static class PermissionAuthorizationExtensions
{
public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, bool oidcEnabled)
public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings)
{
var services = hostBuilder.Services;

Expand All @@ -31,13 +33,21 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h
// policy provider registered by AddAuthorization(). When OIDC is disabled it returns allow-all
// policies (no requirement); when enabled it emits a PermissionRequirement for the verb handler.
services.AddSingleton<IAuthorizationPolicyProvider>(sp =>
new PermissionPolicyProvider(sp.GetRequiredService<IOptions<AuthorizationOptions>>(), oidcEnabled));
new PermissionPolicyProvider(sp.GetRequiredService<IOptions<AuthorizationOptions>>(), oidcSettings.Enabled));

// The role-based handler is only needed when OIDC is enabled — otherwise the provider produces
// no PermissionRequirement for it to evaluate.
if (oidcEnabled)
// no PermissionRequirement for it to evaluate. The handler emits an audit-log entry for every
// decision through IAuthorizationAuditLog (registered alongside) so the platform can show, after
// the fact, who attempted what and how the system responded. The subject-id and subject-name
// claim names flow through from configuration so the handler can read them off the principal.
if (oidcSettings.Enabled)
{
services.AddSingleton<IAuthorizationHandler, PermissionVerbHandler>();
services.AddSingleton<IAuthorizationAuditLog, AuthorizationAuditLog>();
services.AddSingleton<IAuthorizationHandler>(sp =>
new PermissionVerbHandler(
sp.GetRequiredService<IAuthorizationAuditLog>(),
oidcSettings.SubjectIdClaim,
oidcSettings.SubjectNameClaim));
}
}
}
61 changes: 55 additions & 6 deletions src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
#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.Auth;

/// <summary>
/// Verb-level authorization handler for <see cref="PermissionRequirement"/>. It resolves the user's
/// roles and checks them against the hardcoded <see cref="RolePermissions"/> policy: the user must hold
/// a role (e.g. <c>reader</c> / <c>writer</c>) that grants the requested permission.
/// a role (e.g. <c>reader</c> / <c>writer</c>) that grants the requested permission. Every decision is
/// captured through <see cref="IAuthorizationAuditLog"/> for compliance.
/// <para>
/// Only registered — and only reached — when OIDC is enabled. When it is disabled,
/// <see cref="PermissionPolicyProvider"/> returns an allow-all policy that carries no
/// <see cref="PermissionRequirement"/>, so this handler is not needed.
/// </para>
/// </summary>
public sealed class PermissionVerbHandler : AuthorizationHandler<PermissionRequirement>
public sealed class PermissionVerbHandler(
IAuthorizationAuditLog auditLog,
string subjectIdClaim,
string subjectNameClaim)
: AuthorizationHandler<PermissionRequirement>
{
// 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.
Expand All @@ -26,16 +33,58 @@ 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, subjectIdClaim, "Authentication.SubjectIdClaim");
var subjectName = RequireClaim(context.User, subjectNameClaim, "Authentication.SubjectNameClaim");
var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value).ToArray();
var permission = requirement.Permission;

// TODO: Although plural, likely roles will only contain a single value unless we want to define a role for each instance but likely customers don't care about instances
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;
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System;
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));
Assert.That(entries[0].Message, Does.Contain("alice-sub-001"));
Assert.That(entries[0].Message, Does.Contain("Alice Smith"));
Assert.That(entries[0].Message, Does.Contain("error:messages:retry"));
Assert.That(entries[0].Message, Does.Contain("allow"));
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));
Assert.That(entries[0].Message, Does.Contain("bob-sub-002"));
Assert.That(entries[0].Message, Does.Contain("Bob Jones"));
Assert.That(entries[0].Message, Does.Contain("error:messages:retry"));
Assert.That(entries[0].Message, Does.Contain("deny"));
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information));
}

[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(entries[0].Message, Does.Contain("allow"));
Assert.That(entries[1].Message, Does.Contain("deny"));
}

[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<ArgumentException>());
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// In-memory <see cref="ILoggerProvider"/> that captures log entries for test assertions.
/// Thread-safe. Use <see cref="Entries"/> for all captured entries; <see cref="EntriesFor(string)"/>
/// to filter by category.
/// </summary>
sealed class RecordingLoggerProvider : ILoggerProvider
{
readonly ConcurrentQueue<LogEntry> entries = new();

public IReadOnlyList<LogEntry> Entries => entries.ToArray();

public IReadOnlyList<LogEntry> 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<LogEntry> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> 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() { }
}
}
45 changes: 45 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System;
using Microsoft.Extensions.Logging;

/// <summary>
/// Default <see cref="IAuthorizationAuditLog"/> that emits every decision as a structured log entry on
/// the stable category <c>ServiceControl.Audit</c>. Sinks filter on the category, not on the type name.
/// </summary>
public sealed partial class AuthorizationAuditLog : IAuthorizationAuditLog
{
const string AuditCategory = "ServiceControl.Audit";

readonly ILogger logger;

public AuthorizationAuditLog(ILoggerFactory loggerFactory)
{
logger = loggerFactory.CreateLogger(AuditCategory);
}

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);

LogDecision(logger, subjectId, subjectName, permission, resource, allowed ? "allow" : "deny", reason);
}

// Source-generated structured log method — zero allocation on the hot path.
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "Authorization {Outcome}: subjectId={SubjectId} subjectName={SubjectName} permission={Permission} resource={Resource} reason={Reason}")]
static partial void LogDecision(
ILogger logger,
string subjectId,
string subjectName,
string permission,
string? resource,
string outcome,
string reason);
}
Loading
Loading