Skip to content
Open
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 @@ -34,6 +34,18 @@ public OpenIdConnectTestConfiguration WithAuthenticationDisabled()
return this;
}

/// <summary>
/// Enables role-based authorization. When on, controllers carrying
/// <c>[Authorize(Policy = Permissions.X)]</c> require the caller's "roles" claim to map to a
/// role that grants the permission via <c>RolePermissions</c>. When off, the policy provider
/// returns allow-all policies and any authenticated request reaches the controller.
/// </summary>
public OpenIdConnectTestConfiguration WithRoleBasedAuthorizationEnabled()
{
SetEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true");
return this;
}

/// <summary>
/// Disables settings validation. This allows testing with placeholder/fake OIDC settings.
/// Should only be used in test scenarios where a real OIDC provider is not available.
Expand Down Expand Up @@ -164,6 +176,7 @@ public void ClearConfiguration()
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY");
ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED");
ClearEnvironmentVariable("VALIDATECONFIG");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public void ConfigureAuth()
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithServicePulseClientId(TestClientId)
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 @@ -33,6 +33,7 @@ public void ConfigureAuth()
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
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 @@ -12,10 +12,11 @@
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true,
"RequireHttpsMetadata": true,
"RolesClaim": "realm_access.roles",
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null
"ServicePulseApiScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
"ForwardedHeadersSettings": {
"Enabled": true,
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 @@ -37,7 +37,8 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder
ValidateLifetime = oidcSettings.ValidateLifetime,
ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey,
ValidAudience = oidcSettings.Audience,
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew
RoleClaimType = oidcSettings.RolesClaim
};
options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata;
// Don't map inbound claims to legacy Microsoft claim types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace ServiceControl.Hosting.Auth;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ServiceControl.Infrastructure;

/// <summary>
/// Registers the permission-based policy authorization services: a dynamic
Expand All @@ -20,24 +21,25 @@ 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;

// Ensure the authorization core services and options are present (idempotent).
services.AddAuthorization();

// Resolve permission policy names dynamically. Registered last so it supersedes the default
// 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.
// The policy provider is registered UNCONDITIONALLY: every instance hosts controllers with
// [Authorize(Policy = Permissions.X)] attributes, and without a provider that knows those
// policy names ASP.NET throws "AuthorizationPolicy named '...' was not found" → 500 on every
// 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<IAuthorizationPolicyProvider>(sp =>
new PermissionPolicyProvider(sp.GetRequiredService<IOptions<AuthorizationOptions>>(), oidcEnabled));
new PermissionPolicyProvider(sp.GetRequiredService<IOptions<AuthorizationOptions>>(), oidcSettings.RoleBasedAuthorizationEnabled));

// The role-based handler is only needed when OIDC is enabled — otherwise the provider produces
// no PermissionRequirement for it to evaluate.
if (oidcEnabled)
if (oidcSettings.RoleBasedAuthorizationEnabled)
{
services.AddSingleton<IAuthorizationHandler, PermissionVerbHandler>();
services.AddSingleton<IAuthorizationHandler>(_ => new PermissionVerbHandler(oidcSettings.RolesClaim));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ static FrozenDictionary<string, AuthorizationPolicy> BuildPolicies(bool oidcEnab
StringComparer.Ordinal);

public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName) =>
Task.FromResult<AuthorizationPolicy?>(policies.GetValueOrDefault(policyName));
Task.FromResult(policies.GetValueOrDefault(policyName));

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
Expand Down
10 changes: 6 additions & 4 deletions src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ namespace ServiceControl.Hosting.Auth;
/// </summary>
public sealed class PermissionVerbHandler : 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.
const string RoleClaimType = "roles";
public PermissionVerbHandler(string rolesClaimName)
{
RoleClaimType = rolesClaimName;
}

protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value);


// 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))
{
Expand All @@ -38,4 +38,6 @@ protected override Task HandleRequirementAsync(
// Otherwise leave the requirement unmet → the request is denied (403/401).
return Task.CompletedTask;
}

internal string RoleClaimType = "roles";
}
6 changes: 1 addition & 5 deletions src/ServiceControl.Infrastructure/Auth/RolePermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ServiceControl.Infrastructure.Auth;
using System.Linq;

/// <summary>
/// Hardcoded role → permission policy. Two roles for now:
/// Role → permission policy. Two roles:
/// <list type="bullet">
/// <item><c>reader</c> — granted every <c>*:*:view</c> permission (read-only access).</item>
/// <item><c>writer</c> — granted every permission (<c>*:*:*</c>).</item>
Expand All @@ -17,10 +17,6 @@ namespace ServiceControl.Infrastructure.Auth;
/// immutable <see cref="FrozenSet{T}"/> of granted permissions per role. As a result both
/// <see cref="IsGranted"/> and <see cref="GetPermissions(string)"/> are O(1) hash lookups with no
/// per-call pattern matching or allocation.
/// <para>
/// TODO: interim hardcoded model — replace with a configurable role/permission mapping (loaded from
/// configuration or the IdP) when more than these two coarse roles are needed.
/// </para>
/// </summary>
public static class RolePermissions
{
Expand Down
32 changes: 17 additions & 15 deletions src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,8 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true);
RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true);

// Path within the JWT to the user's role values. May be a flat claim name (e.g. "roles" — the
// shape produced by Keycloak with a "User Realm Role" mapper, by Microsoft Entra ID, or by
// AWS Cognito as "cognito:groups") or a dotted path into a nested object claim (e.g. the
// Keycloak out-of-box shape "realm_access.roles"). The RolesClaimsTransformation reads from
// this path and flattens the values into canonical "roles" claims for the authorization handler.
RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "realm_access.roles");
RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles");
RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false);

// ServicePulse settings are only relevant for the primary ServiceControl instance
// which serves the OIDC configuration endpoint that ServicePulse uses for login
Expand Down Expand Up @@ -103,15 +99,6 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
/// </summary>
public bool RequireHttpsMetadata { get; }

/// <summary>
/// Path within the JWT where the user's role values live. Defaults to <c>realm_access.roles</c>
/// to match Keycloak's out-of-box token shape. A flat claim name like <c>roles</c> is used when
/// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role"
/// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates
/// into a nested JSON object claim.
/// </summary>
public string RolesClaim { get; }

/// <summary>
/// Optional override for the authority URL that ServicePulse should use for authentication.
/// If not specified, ServicePulse uses the main Authority value.
Expand All @@ -130,6 +117,21 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
/// </summary>
public string ServicePulseApiScopes { get; }

/// <summary>
/// Path within the JWT where the user's role values live. Defaults to <c>realm_access.roles</c>
/// to match Keycloak's out-of-box token shape. A flat claim name like <c>roles</c> is used when
/// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role"
/// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates
/// into a nested JSON object claim.
/// </summary>
public string RolesClaim { get; }

/// <summary>
/// Is RBAC enabled. When false, all authenticated users have access to all methods. When true,
/// role based authorization rules are applied.
/// </summary>
public bool RoleBasedAuthorizationEnabled { get; }

void Validate(bool requireServicePulseSettings)
{
if (Enabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public void ConfigureAuth()
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async Task InitializeServiceControl(ScenarioContext context)
hostBuilder.Logging.ConfigureLogging(LogLevel.Information);

hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) =>
{
var logitem = new ScenarioContext.LogItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true,
"RequireHttpsMetadata": true,
"RolesClaim": "realm_access.roles",
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null
"ServicePulseApiScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
"ForwardedHeadersSettings": {
"Enabled": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration);
hostBuilder.AddServiceControlMonitoringApi();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true,
"RequireHttpsMetadata": true,
"RolesClaim": "realm_access.roles",
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null
"ServicePulseApiScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
"ForwardedHeadersSettings": {
"Enabled": true,
Expand Down
2 changes: 1 addition & 1 deletion src/ServiceControl/Hosting/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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.AddServiceControl(settings, endpointConfiguration);
hostBuilder.AddServiceControlApi(settings.CorsSettings);
Expand Down
Loading