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 @@ -126,7 +126,7 @@ public async Task Should_accept_requests_with_valid_bearer_token()
_ = await Define<Context>()
.Done(async ctx =>
{
// The "reader" role grants every *:*:view permission, including error:messages:view
// The "reader" role grants every :view permission, including error:messages:view
// required by /api/errors. Without a role-bearing claim the request would be 403.
var validToken = mockOidcServer.GenerateToken(
additionalClaims: new[] { new Claim("roles", "reader") });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,48 +63,5 @@ public async Task Should_reject_requests_without_bearer_token()
OpenIdConnectAssertions.AssertUnauthorized(response);
}

[Test]
public async Task Reader_can_view_but_cannot_retry()
{
var routes = await GetRoutes(RolePermissions.Reader);

using (Assert.EnterMultipleScope())
{
Assert.That(routes.Any(r => r.UrlTemplate == "/api/configuration"), Is.True,
"reader holds :view permissions, so view routes are allowed");
Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.False,
"reader has no retry permission, so retry routes are excluded");
}
}

[Test]
public async Task Writer_can_retry()
{
var routes = await GetRoutes(RolePermissions.Writer);

Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.True,
"writer holds every permission, so retry routes are allowed");
}

async Task<List<RouteManifestEntry>> GetRoutes(string role)
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]);
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient, HttpMethod.Get, "/api/my/routes", token);
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertAuthenticated(response);

var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<RouteManifestEntry>>(content, SerializerOptions);
}

class Context : ScenarioContext;
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public async Task Should_accept_requests_with_valid_bearer_token()
_ = await Define<Context>()
.Done(async ctx =>
{
// The "reader" role grants every *:*:view permission, including audit:message:view
// The "reader" role grants every :view permission, including audit:message:view
// required by /api/messages. Without a role-bearing claim the request would be 403.
var validToken = mockOidcServer.GenerateToken(
additionalClaims: new[] { new Claim("roles", "reader") });
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System.Linq;
using NUnit.Framework;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
class RolePermissionsTests
{
[Test]
public void Every_permission_is_assigned_to_a_role()
{
var unassigned = Permissions.All
.Except(RolePermissions.Roles[RolePermissions.Admin])
.Order()
.ToArray();

Assert.That(unassigned, Is.Empty,
$"Every permission constant must be assigned to a role group in RolePermissions. Unassigned: [{string.Join(", ", unassigned)}]");
}
}
16 changes: 12 additions & 4 deletions src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

/// <summary>
/// The set of permissions a principal effectively holds, computed per request. Mirrors the inputs the
/// enforcement handler uses: when role-based authorization is enabled, the union of the permissions
/// granted by the principal's <see cref="ClaimTypes.Role"/> claims (via <see cref="RolePermissions"/>);
/// granted by the principal's <see cref="ClaimTypes.Role"/> claims (via <see cref="RolePermissions.Roles"/>);
/// when it is disabled the platform runs allow-all, so every known permission is held.
/// </summary>
public static class EffectivePermissions
Expand All @@ -20,7 +20,15 @@ public static IReadOnlySet<string> ForUser(ClaimsPrincipal user, OpenIdConnectSe
return Permissions.All;
}

var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value);
return RolePermissions.GetPermissions(roles);
var permissions = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in user.FindAll(ClaimTypes.Role))
{
if (RolePermissions.Roles.TryGetValue(claim.Value, out var granted))
{
permissions.UnionWith(granted);
}
}

return permissions;
}
}
2 changes: 1 addition & 1 deletion src/ServiceControl.Infrastructure/Auth/Permissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public static class Permissions
/// <inheritdoc cref="ErrorThroughputView"/>
public const string ErrorThroughputManage = "error:throughput:manage";

/// <summary>Platform connections area — viewing and managing broker/platform connection settings.</summary>
/// <summary>Platform connections area — viewing and managing platform connection settings.</summary>
public const string ErrorConnectionsView = "error:connections:view";
/// <inheritdoc cref="ErrorConnectionsView"/>
public const string ErrorConnectionsManage = "error:connections:manage";
Expand Down
177 changes: 64 additions & 113 deletions src/ServiceControl.Infrastructure/Auth/RolePermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,136 +4,87 @@ namespace ServiceControl.Infrastructure.Auth;
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// 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>
/// </list>
/// The wildcard patterns (<c>*</c> is a colon-segment wildcard) are the source of truth, but they are
/// <b>expanded once</b> at type initialization against <see cref="Permissions.All"/> into a concrete,
/// 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.
/// </summary>
public static class RolePermissions
{
/// <summary>Read-only role: every <c>*:*:view</c> permission.</summary>
public const string Reader = "reader";

/// <summary>Full-access role: every permission.</summary>
public const string Writer = "writer";

/// <summary>
/// Platform-administrator role: read-only on everything, plus full management of the configuration /
/// admin-area resources (licensing, notifications, retry redirects, throughput, connections) — but
/// <b>not</b> the message-triage write actions (retry/edit/archive/restore).
/// </summary>
public const string Admin = "admin";

// Source of truth: the wildcard pattern(s) each role grants.
static readonly Dictionary<string, string[]> RolePatterns = new(StringComparer.OrdinalIgnoreCase)
{
[Reader] = ["*:*:view"],
[Writer] = ["*:*:*"],
[Admin] =
[
"*:*:view",
"error:licensing:*",
"error:notifications:*",
"error:redirects:*",
"error:throughput:*",
"error:connections:*",
],
};
static readonly string[] Read =
[
Permissions.ErrorMessagesView,
Permissions.ErrorRecoverabilityGroupsView,
Permissions.ErrorEndpointsView,
Permissions.ErrorHeartbeatsView,
Permissions.ErrorCustomChecksView,
Permissions.ErrorSagasView,
Permissions.ErrorEventLogView,
Permissions.ErrorQueuesView,
Permissions.ErrorConnectionsView,
Permissions.AuditMessageView,
Permissions.AuditConnectionView,
Permissions.AuditEndpointView,
Permissions.AuditSagaView,
Permissions.MonitoringEndpointView,
Permissions.MonitoringConnectionView,
Permissions.MonitoringLicenseView,
];

static readonly string[] ReadConfiguration =
[
Permissions.ErrorLicensingView,
Permissions.ErrorNotificationsView,
Permissions.ErrorRedirectsView,
Permissions.ErrorThroughputView,
];

static readonly string[] Operate =
[
Permissions.ErrorMessagesRetry,
Permissions.ErrorMessagesArchive,
Permissions.ErrorMessagesUnarchive,
Permissions.ErrorMessagesEdit,
Permissions.ErrorRecoverabilityGroupsRetry,
Permissions.ErrorRecoverabilityGroupsArchive,
Permissions.ErrorRecoverabilityGroupsUnarchive,
Permissions.ErrorEndpointsManage,
Permissions.ErrorEndpointsDelete,
Permissions.ErrorCustomChecksDelete,
Permissions.ErrorQueuesDelete,
Permissions.ErrorConnectionsManage,
Permissions.MonitoringEndpointDelete,
];

static readonly string[] Configure =
[
Permissions.ErrorLicensingManage,
Permissions.ErrorNotificationsManage,
Permissions.ErrorNotificationsTest,
Permissions.ErrorRedirectsManage,
Permissions.ErrorThroughputManage,
];

public static readonly FrozenDictionary<string, FrozenSet<string>> Roles =
new Dictionary<string, FrozenSet<string>>(StringComparer.OrdinalIgnoreCase)
{
[Reader] = ToSet([.. Read, .. ReadConfiguration]),
[Writer] = ToSet([.. Read, .. Operate]),
[Admin] = ToSet([.. Read, .. ReadConfiguration, .. Operate, .. Configure]),
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

// Expanded once against the full permission catalogue: role -> concrete granted permissions.
static readonly FrozenDictionary<string, FrozenSet<string>> PermissionsByRole = Expand();
static FrozenSet<string> ToSet(string[] permissions) => permissions.ToFrozenSet(StringComparer.Ordinal);

/// <summary>
/// Returns <see langword="true"/> if any of the supplied <paramref name="roles"/> grants the
/// requested <paramref name="permission"/>. O(1) per role — a frozen-set membership test.
/// </summary>
public static bool IsGranted(IEnumerable<string> roles, string permission)
public static bool IsGranted(string[] roles, string permission)
{
foreach (var role in roles)
{
if (PermissionsByRole.TryGetValue(role, out var granted) && granted.Contains(permission))
if (Roles.TryGetValue(role, out var granted) && granted.Contains(permission))
{
return true;
}
}

return false;
}

/// <summary>
/// The complete set of permissions granted to a single role (empty if the role is unknown).
/// O(1) and allocation-free — returns the precomputed frozen set.
/// </summary>
public static IReadOnlySet<string> GetPermissions(string role) =>
PermissionsByRole.TryGetValue(role, out var granted) ? granted : FrozenSet<string>.Empty;

/// <summary>
/// The union of permissions granted across several <paramref name="roles"/>. Allocation-free for the
/// common single-role case; only the multi-role union allocates.
/// </summary>
public static IReadOnlySet<string> GetPermissions(IEnumerable<string> roles)
{
var list = roles as IReadOnlyList<string> ?? roles.ToList();
if (list.Count <= 1)
{
return list.Count == 0 ? FrozenSet<string>.Empty : GetPermissions(list[0]);
}

var union = new HashSet<string>(StringComparer.Ordinal);
foreach (var role in list)
{
if (PermissionsByRole.TryGetValue(role, out var granted))
{
union.UnionWith(granted);
}
}

return union;
}

static FrozenDictionary<string, FrozenSet<string>> Expand()
{
var expanded = new Dictionary<string, FrozenSet<string>>(StringComparer.OrdinalIgnoreCase);

foreach (var (role, patterns) in RolePatterns)
{
expanded[role] = Permissions.All
.Where(permission => patterns.Any(pattern => Matches(pattern, permission)))
.ToFrozenSet(StringComparer.Ordinal);
}

return expanded.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}

/// <summary>Matches a colon-delimited permission against a pattern where <c>*</c> is a segment wildcard.</summary>
static bool Matches(string pattern, string permission)
{
var patternSegments = pattern.Split(':');
var permissionSegments = permission.Split(':');

if (patternSegments.Length != permissionSegments.Length)
{
return false;
}

for (var i = 0; i < patternSegments.Length; i++)
{
if (patternSegments[i] != "*"
&& !string.Equals(patternSegments[i], permissionSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public async Task Should_accept_requests_with_valid_bearer_token()
_ = await Define<Context>()
.Done(async ctx =>
{
// The "reader" role grants every *:*:view permission, including
// The "reader" role grants every :view permission, including
// monitoring:endpoint:view required by /monitored-endpoints. Without a
// role-bearing claim the request would be 403.
var validToken = mockOidcServer.GenerateToken(
Expand Down
Loading