Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
032a033
👷 Add YamlDotNet package for the RBAC policy file
ramonsmits May 22, 2026
62ab4a6
✨ Add Authentication.RbacPolicyFile setting
ramonsmits May 22, 2026
abd2a28
✨ Add RBAC policy model types
ramonsmits May 22, 2026
d56b01e
✨ Add rbac.yaml loader and default policy file
ramonsmits May 22, 2026
ca62f0d
✨ Add resource-scope pattern matching
ramonsmits May 22, 2026
0eb84e6
✨ Flatten Keycloak realm_access roles into claims
ramonsmits May 22, 2026
48088e9
✨ Add IPermissionEvaluator
ramonsmits May 22, 2026
e1cccad
✨ Add permission catalogue
ramonsmits May 22, 2026
4ed9de0
✅ Address RBAC foundation review findings
ramonsmits May 22, 2026
4f64eae
✨ Add authorization-decision audit logging
ramonsmits May 22, 2026
a2d98b0
✨ Add descriptor endpoint, authorization wiring, and completeness tes…
ramonsmits May 22, 2026
7c1a9d3
✅ Complete the Phase 0 endpoint baseline
ramonsmits May 22, 2026
269b0cb
🐛 Fix code-review findings: 404 on auth-disabled, grant dedup, naming…
ramonsmits May 22, 2026
ce6abc2
✨ Add full RBAC permission catalogue, expanded rbac.yaml, and cross-c…
ramonsmits May 28, 2026
c549bee
🔀 Merge tf3651-authz-base: foundation
ramonsmits May 28, 2026
784bed0
✨ S2 mechanism: PermissionVerbHandler, IResourceScopeChecker, S2Autho…
ramonsmits May 28, 2026
ddef8fa
✅ Add S2 acceptance tests for retry authorization (allow/deny/scope/a…
ramonsmits May 28, 2026
b87621c
🐛 Fix All_endpoints test: enumerate endpoints inside Done() while Ser…
ramonsmits May 28, 2026
c5f713c
✨ Add HasUnrestrictedGrant/ResolveQueueScope to IPermissionEvaluator …
ramonsmits May 28, 2026
f38d99c
✨ S2 enforcement: wire [RequirePermission] across all messages & reco…
ramonsmits May 28, 2026
dd81c55
♻️ Remove messages & recoverability from KnownUnenforcedPermissions a…
ramonsmits May 28, 2026
3d69b56
✅ Add S2 acceptance tests for all messages & recoverability authoriza…
ramonsmits May 28, 2026
d047bcc
🐛 Fix 4 failing security acceptance tests
ramonsmits May 28, 2026
29702e4
♻️ Drop redundant RequirePermission attribute
ramonsmits May 28, 2026
fcf7fc6
🔀 Merge base: drop RequirePermission marker
ramonsmits May 28, 2026
9f8ab74
♻️ Drop redundant RequirePermission attribute
ramonsmits May 28, 2026
90a7f9b
🐛 Fix FilterByQueueScope: lowercase patterns and correct deny-prefix …
ramonsmits May 28, 2026
a1a393a
🐛 D4: Enforce recoverabilitygroups:view on DELETE unacknowledgedgroup…
ramonsmits May 28, 2026
2deaabf
♻️ D7: Route group-operation 403 responses through AuthorizationHelpe…
ramonsmits May 28, 2026
1e6c209
🐛 Backport: Fail-closed EditComment/DeleteComment for scoped users on S2
ramonsmits May 28, 2026
cab16dd
🐛 Fix ResourceScope.Permits: lowercase resource as well as pattern fo…
ramonsmits May 28, 2026
722202d
🔧 Fix pre-existing nullable errors in RavenDB persistence layer (S2)
ramonsmits May 28, 2026
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
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.8" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.8" />
<PackageVersion Include="Validar.Fody" Version="1.9.0" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup Label="Versions to pin transitive references">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#nullable enable
namespace ServiceControl.AcceptanceTesting.Auth;

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;

/// <summary>
/// An in-memory <see cref="ILoggerProvider"/> that captures log entries for test assertions.
/// Thread-safe. Use <see cref="Entries"/> to read all captured entries; use
/// <see cref="EntriesFor(string)"/> to filter by category.
/// </summary>
public 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() { /* nothing to release */ }
}

/// <summary>A captured log entry.</summary>
public 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() { }
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
namespace ServiceControl.AcceptanceTesting
{
using System;
using System.Net.Http;
using System.Text.Json;

public interface IAcceptanceTestInfrastructureProvider
{
HttpClient HttpClient { get; }
JsonSerializerOptions SerializerOptions { get; }

/// <summary>
/// The DI container of the running ServiceControl host.
/// Exposed so tests can resolve internal services (e.g. <c>IEnumerable&lt;EndpointDataSource&gt;</c>
/// for the endpoint-completeness test) without coupling to a specific host type.
/// </summary>
IServiceProvider Services { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,30 @@ public string GenerateToken(
return new JwtSecurityTokenHandler().WriteToken(token);
}

/// <summary>
/// Generates a valid JWT token carrying Keycloak-style realm_access roles.
/// The <see cref="RealmAccessClaimsTransformation"/> will expand these into
/// individual <c>role</c> claims so the RBAC evaluator can match them.
/// </summary>
/// <param name="subject">The subject (sub) claim.</param>
/// <param name="realmRoles">The roles to embed in <c>realm_access.roles</c>.</param>
/// <param name="expiresIn">Token lifetime (default 1 hour).</param>
public string GenerateTokenWithRealmRoles(
string subject = "test-user",
IEnumerable<string> realmRoles = null,
TimeSpan? expiresIn = null)
{
var roles = realmRoles == null ? Array.Empty<string>() : new List<string>(realmRoles).ToArray();
var realmAccessJson = JsonSerializer.Serialize(new { roles });

var additionalClaims = new List<Claim>
{
new("realm_access", realmAccessJson, Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json)
};

return GenerateToken(subject, expiresIn, additionalClaims);
}

/// <summary>
/// Generates an expired JWT token for testing token expiration.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#nullable enable
namespace ServiceControl.AcceptanceTests.Security.Authorization;

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using NUnit.Framework;
using ServiceControl.Connection;
using ServiceControl.Infrastructure.Auth.Rbac;

/// <summary>
/// Fast unit-style tests (no host startup) that keep the permission catalogue honest.
/// <para>
/// Two invariants are asserted:
/// <list type="number">
/// <item>
/// Every constant in <see cref="Permissions.All"/> (except <c>*</c>) is either enforced
/// via an <c>[Authorize(Policy = X)]</c> attribute on a controller action (where <c>X</c> is
/// a member of <see cref="Permissions.All"/>), or explicitly declared as unenforced in
/// <see cref="KnownUnenforcedPermissions.Set"/>.
/// Failing this means a new constant was added without enforcement or a known-unenforced entry.
/// </item>
/// <item>
/// Every entry in <see cref="KnownUnenforcedPermissions.Set"/> is genuinely unenforced.
/// Failing this means enforcement was added but the known-unenforced entry was not removed.
/// </item>
/// </list>
/// </para>
/// <para>
/// Assembly walk strategy:
/// <list type="bullet">
/// <item>
/// All controllers live in the main ServiceControl assembly, anchored via
/// <see cref="ConnectionController"/> (a stable, non-auth-specific controller type).
/// </item>
/// <item>
/// <see cref="Permissions"/> and <see cref="KnownUnenforcedPermissions"/> live in the
/// Infrastructure assembly, anchored via <see cref="Permissions"/>.
/// </item>
/// </list>
/// All assemblies scanned are already referenced by the test project; no runtime loading is needed.
/// </para>
/// </summary>
[TestFixture]
public class Catalogue_completeness_tests
{
/// <summary>
/// Collects all permission strings that appear as the <c>Policy</c> of an
/// <c>[Authorize]</c> attribute on any controller class or action method across the
/// ServiceControl assembly, filtered to only those that are members of <see cref="Permissions.All"/>.
/// </summary>
static IReadOnlySet<string> CollectEnforcedPermissions()
{
// The main ServiceControl assembly hosts all controllers.
// Anchored via ConnectionController — a stable, always-present controller type.
var scAssembly = typeof(ConnectionController).Assembly;

var enforced = new HashSet<string>(StringComparer.Ordinal);

foreach (var type in scAssembly.GetExportedTypes())
{
// Check class-level [Authorize(Policy = X)]
foreach (var attr in type.GetCustomAttributes<AuthorizeAttribute>(inherit: true))
{
if (attr.Policy != null && Permissions.All.Contains(attr.Policy))
{
enforced.Add(attr.Policy);
}
}

// Check method-level [Authorize(Policy = X)] on all public instance methods
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
foreach (var attr in method.GetCustomAttributes<AuthorizeAttribute>(inherit: true))
{
if (attr.Policy != null && Permissions.All.Contains(attr.Policy))
{
enforced.Add(attr.Policy);
}
}
}
}

return enforced;
}

[Test]
public void Every_catalogue_constant_is_either_enforced_or_declared_as_known_unenforced()
{
var enforced = CollectEnforcedPermissions();

// All declared permissions except the wildcard grant ("*" is not a real permission)
var catalogue = Permissions.All
.Where(p => p != "*")
.ToHashSet(StringComparer.Ordinal);

// Unenforced = declared but not yet wired to any controller action
var unenforced = catalogue
.Except(enforced)
.ToHashSet(StringComparer.Ordinal);

// Any permission that is unenforced AND not listed in KnownUnenforcedPermissions is a gap
var gaps = unenforced
.Except(KnownUnenforcedPermissions.Set)
.OrderBy(p => p)
.ToList();

if (gaps.Count > 0)
{
Assert.Fail(
$"The following permission(s) are declared in Permissions but are neither enforced " +
$"by an [Authorize(Policy = X)] attribute on a controller action nor listed in KnownUnenforcedPermissions.Set.\n" +
$"Either add [Authorize(Policy = \"{gaps[0]}\")] to the appropriate controller action, " +
$"or add the constant to KnownUnenforcedPermissions.Set until enforcement is implemented:\n" +
string.Join("\n", gaps.Select(p => $" - {p}")));
}
}

[Test]
public void KnownUnenforced_set_contains_no_stale_entries()
{
var enforced = CollectEnforcedPermissions();

// All declared permissions except the wildcard grant ("*" is not a real permission)
var catalogue = Permissions.All
.Where(p => p != "*")
.ToHashSet(StringComparer.Ordinal);

// Unenforced = declared but not yet wired to any controller action
var unenforced = catalogue
.Except(enforced)
.ToHashSet(StringComparer.Ordinal);

// KnownUnenforced entries that are now enforced (stale)
var stale = KnownUnenforcedPermissions.Set
.Except(unenforced)
.OrderBy(p => p)
.ToList();

if (stale.Count > 0)
{
Assert.Fail(
$"The following permission(s) are listed in KnownUnenforcedPermissions.Set but are " +
$"now enforced by an [Authorize(Policy = X)] attribute — remove them from " +
$"KnownUnenforcedPermissions.Set to keep the set accurate:\n" +
string.Join("\n", stale.Select(p => $" - {p}")));
}
}
}
Loading
Loading