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 @@ -13,6 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\Particular.LicensingComponent.Persistence\Particular.LicensingComponent.Persistence.csproj" />
<ProjectReference Include="..\ServiceControl.Api\ServiceControl.Api.csproj" />
<ProjectReference Include="..\ServiceControl.Infrastructure\ServiceControl.Infrastructure.csproj" />
<ProjectReference Include="..\ServiceControl.Transports\ServiceControl.Transports.csproj" />
</ItemGroup>

Expand Down
10 changes: 10 additions & 0 deletions src/Particular.LicensingComponent/WebApi/LicensingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
using System.Text.Json;
using System.Threading;
using Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Particular.LicensingComponent.Report;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api/licensing")]
Expand All @@ -19,13 +21,15 @@ public LicensingController(IThroughputCollector throughputCollector)
this.throughputCollector = throughputCollector;
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("endpoints")]
[HttpGet]
public async Task<List<EndpointThroughputSummary>> GetEndpointThroughput(CancellationToken cancellationToken)
{
return await throughputCollector.GetThroughputSummary(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputManage)]
[Route("endpoints/update")]
[HttpPost]
public async Task<IActionResult> UpdateUserSelectionOnEndpointThroughput(List<UpdateUserIndicator> updateUserIndicators, CancellationToken cancellationToken)
Expand All @@ -34,13 +38,15 @@ public async Task<IActionResult> UpdateUserSelectionOnEndpointThroughput(List<Up
return Ok();
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("report/available")]
[HttpGet]
public async Task<ReportGenerationState> CanThroughputReportBeGenerated(CancellationToken cancellationToken)
{
return await throughputCollector.GetReportGenerationState(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("report/file")]
[HttpGet]
public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken)
Expand Down Expand Up @@ -77,24 +83,28 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string
await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/info")]
[HttpGet]
public async Task<ThroughputConnectionSettings> GetThroughputSettingsInformation(CancellationToken cancellationToken)
{
return await throughputCollector.GetThroughputConnectionSettingsInformation(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/test")]
[HttpGet]
public async Task<ConnectionTestResults> TestThroughputConnectionSettings(CancellationToken cancellationToken) => await throughputCollector.TestConnectionSettings(cancellationToken);

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/masks")]
[HttpGet]
public async Task<List<string>> GetMasks(CancellationToken cancellationToken)
{
return await throughputCollector.GetReportMasks(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputManage)]
[Route("settings/masks/update")]
[HttpPost]
public async Task<IActionResult> UpdateMasks(List<string> updateMasks, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView;
using System.Threading.Tasks;
using Infrastructure;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api")]
public class GetMessages2Controller(IAuditDataStore dataStore) : ControllerBase
{
[Authorize(Policy = Permissions.AuditMessageView)]
[Route("messages2")]
[HttpGet]
public async Task<IList<MessagesView>> GetAllMessages(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ namespace ServiceControl.Audit.Auditing.MessagesView
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api")]
public class GetMessagesController(IAuditDataStore dataStore) : ControllerBase
{
[Authorize(Policy = Permissions.AuditMessageView)]
[Route("messages")]
[HttpGet]
public async Task<IList<MessagesView>> GetAllMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, CancellationToken cancellationToken)
Expand All @@ -23,6 +25,7 @@ public async Task<IList<MessagesView>> GetAllMessages([FromQuery] PagingInfo pag
return result.Results;
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("endpoints/{endpoint}/messages")]
[HttpGet]
public async Task<IList<MessagesView>> GetEndpointMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, string endpoint, CancellationToken cancellationToken)
Expand All @@ -43,6 +46,7 @@ public async Task<IList<AuditCount>> GetEndpointAuditCounts([FromQuery] PagingIn
return result.Results;
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("messages/{id}/body")]
[HttpGet]
public async Task<IActionResult> Get(string id, CancellationToken cancellationToken)
Expand All @@ -69,6 +73,7 @@ public async Task<IActionResult> Get(string id, CancellationToken cancellationTo
return result.StringContent != null ? Content(result.StringContent, contentType) : File(result.StreamContent, contentType);
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("messages/search")]
[HttpGet]
public async Task<IList<MessagesView>> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string q, CancellationToken cancellationToken)
Expand All @@ -78,6 +83,7 @@ public async Task<IList<MessagesView>> Search([FromQuery] PagingInfo pagingInfo,
return result.Results;
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("messages/search/{keyword}")]
[HttpGet]
public async Task<IList<MessagesView>> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string keyword, CancellationToken cancellationToken)
Expand All @@ -87,6 +93,7 @@ public async Task<IList<MessagesView>> SearchByKeyWord([FromQuery] PagingInfo pa
return result.Results;
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("endpoints/{endpoint}/messages/search")]
[HttpGet]
public async Task<IList<MessagesView>> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string q, CancellationToken cancellationToken)
Expand All @@ -96,6 +103,7 @@ public async Task<IList<MessagesView>> Search([FromQuery] PagingInfo pagingInfo,
return result.Results;
}

[Authorize(Policy = Permissions.AuditMessageView)]
[Route("endpoints/{endpoint}/messages/search/{keyword}")]
[HttpGet]
public async Task<IList<MessagesView>> SearchByKeyword([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string keyword, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView
using System.Threading.Tasks;
using Infrastructure;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api")]
public class MessagesConversationController(IAuditDataStore dataStore) : ControllerBase
{
[Authorize(Policy = Permissions.AuditMessageView)]
[Route("conversations/{conversationId}")]
[HttpGet]
public async Task<IList<MessagesView>> Get([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string conversationId, CancellationToken cancellationToken)
Expand Down
3 changes: 3 additions & 0 deletions src/ServiceControl.Audit/Connection/ConnectionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ namespace ServiceControl.Audit.Connection
{
using System.Text.Json;
using Infrastructure.Settings;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api")]
Expand All @@ -11,6 +13,7 @@ public class ConnectionController(Settings settings) : ControllerBase
// This controller doesn't use the default serialization settings because
// ServicePulse and the Platform Connector Plugin expect the connection
// details the be serialized and formatted in a specific way
[Authorize(Policy = Permissions.AuditConnectionView)]
[Route("connection")]
[HttpGet]
public IActionResult GetConnectionDetails() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAudit((_, __) =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Monitoring
using System.Threading.Tasks;
using Infrastructure;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api")]
public class KnownEndpointsController(IAuditDataStore dataStore) : ControllerBase
{
[Authorize(Policy = Permissions.AuditEndpointView)]
[Route("endpoints/known")]
[HttpGet]
public async Task<IList<KnownEndpointsView>> GetAll([FromQuery] PagingInfo pagingInfo, CancellationToken cancellationToken)
Expand Down
3 changes: 3 additions & 0 deletions src/ServiceControl.Audit/SagaAudit/SagasController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ namespace ServiceControl.Audit.SagaAudit
using System.Threading.Tasks;
using Infrastructure;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence;
using ServiceControl.Infrastructure.Auth;
using ServiceControl.SagaAudit;

[ApiController]
[Route("api")]
public class SagasController(IAuditDataStore dataStore) : ControllerBase
{
[Authorize(Policy = Permissions.AuditSagaView)]
[Route("sagas/{id}")]
[HttpGet]
public async Task<SagaHistory> Sagas([FromQuery] PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

/// <summary>
/// Registers the permission-based policy authorization services: a dynamic
/// <see cref="PermissionPolicyProvider"/> that resolves <c>[Authorize(Policy = "&lt;permission&gt;")]</c>
/// attributes, and — when OIDC is enabled — the <see cref="PermissionVerbHandler"/> that evaluates them
/// against the user's roles.
/// <para>
/// The provider is registered unconditionally so the policy attributes resolve in every configuration
/// (without it, annotated endpoints fail with "AuthorizationPolicy not found"). When OIDC is disabled the
/// provider returns allow-all policies that carry no requirement, so the verb handler is not registered.
/// Wire this into every instance that hosts annotated controllers (Error, Audit, Monitoring).
/// </para>
/// </summary>
public static class PermissionAuthorizationExtensions
{
public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, bool oidcEnabled)
{
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.
services.AddSingleton<IAuthorizationPolicyProvider>(sp =>
new PermissionPolicyProvider(sp.GetRequiredService<IOptions<AuthorizationOptions>>(), oidcEnabled));

// The role-based handler is only needed when OIDC is enabled — otherwise the provider produces
// no PermissionRequirement for it to evaluate.
if (oidcEnabled)
{
services.AddSingleton<IAuthorizationHandler, PermissionVerbHandler>();
}
}
}
59 changes: 59 additions & 0 deletions src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ServiceControl.Infrastructure.Auth;

/// <summary>
/// A dynamic <see cref="IAuthorizationPolicyProvider"/> that resolves a verb-level authorization policy
/// for each known permission string (e.g. <c>error:messages:retry</c>).
/// <para>
/// The set of valid policy names is known up front (<see cref="Permissions.All"/>), so every policy is
/// <b>built once</b> at construction into a <see cref="FrozenDictionary{TKey, TValue}"/>. The framework
/// calls <see cref="GetPolicyAsync"/> on every request to a protected endpoint, so this makes that call
/// an O(1) lookup with no per-request policy allocation. (Authorization policies and requirements are
/// immutable, so the prebuilt instances are safely shared across all requests.)
/// </para>
/// <para>
/// When OIDC is enabled each permission maps to a policy carrying a <see cref="PermissionRequirement"/>
/// (evaluated by <see cref="PermissionVerbHandler"/>). When OIDC is disabled the platform runs
/// unauthenticated, so every permission maps to a shared allow-all policy — no requirement, no handler.
/// Unknown policy names resolve to <see langword="null"/>; the default and fallback policies are
/// delegated to the configured <see cref="AuthorizationOptions"/>.
/// </para>
/// </summary>
public sealed class PermissionPolicyProvider(IOptions<AuthorizationOptions> authorizationOptions, bool oidcEnabled)
: IAuthorizationPolicyProvider
{
// Carries no requirement, so it succeeds without any IAuthorizationHandler being registered.
static readonly AuthorizationPolicy AllowAll =
new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build();

readonly FrozenDictionary<string, AuthorizationPolicy> policies = BuildPolicies(oidcEnabled);

static FrozenDictionary<string, AuthorizationPolicy> BuildPolicies(bool oidcEnabled) =>
Permissions.All.ToFrozenDictionary(
permission => permission,
permission => oidcEnabled
? new AuthorizationPolicyBuilder().AddRequirements(new PermissionRequirement(permission)).Build()
: AllowAll,
StringComparer.Ordinal);

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

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
var defaultPolicy = authorizationOptions.Value.DefaultPolicy
?? new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
return Task.FromResult(defaultPolicy);
}

public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> Task.FromResult(authorizationOptions.Value.FallbackPolicy);
}
11 changes: 11 additions & 0 deletions src/ServiceControl.Hosting/Auth/PermissionRequirement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using Microsoft.AspNetCore.Authorization;

/// <summary>
/// An <see cref="IAuthorizationRequirement"/> that carries the permission string enforced by a
/// <c>[Authorize(Policy = "&lt;permission&gt;")]</c> attribute (e.g. <c>error:messages:view</c>).
/// Evaluated by <see cref="PermissionVerbHandler"/>.
/// </summary>
public sealed record PermissionRequirement(string Permission) : IAuthorizationRequirement;
44 changes: 44 additions & 0 deletions src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using System.Linq;
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.
/// <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>
{
// TODO: The claim that carries a user's roles is identity-provider specific and must become
// configurable (per-IdP) rather than hardcoded. Roles are expected as a flat, multivalued claim;
// the token handler splits a top-level JSON array into individual claims, so no parsing is needed.
// For Keycloak, add a "User Realm Role" protocol mapper with Multivalued = ON and Token Claim Name
// = "roles" (a dotted name like "realm_access.roles" would nest it into an object instead).
const string RoleClaimType = "roles";

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))
{
context.Succeed(requirement);
}

// Otherwise leave the requirement unmet → the request is denied (403/401).
return Task.CompletedTask;
}
}
Loading
Loading