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
59 changes: 59 additions & 0 deletions src/ServiceControl.Infrastructure.Tests/Auth/PermissionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

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

[TestFixture]
public class PermissionTests
{
[Test]
public void Every_const_permission_round_trips_through_the_typed_model()
{
foreach (var value in Permissions.All)
{
Assert.That(PermissionId.TryParse(value, out var permission), Is.True, $"'{value}' did not parse.");
Assert.That(permission.ToString(), Is.EqualTo(value), $"'{value}' did not round-trip.");
}
}

[Test]
public void Typed_catalogue_matches_the_const_catalogue()
{
var typed = PermissionId.All.Select(p => p.ToString());

Assert.That(typed, Is.EquivalentTo(Permissions.All));
}

[Test]
public void TryParse_rejects_a_well_typed_but_unknown_triple()
{
// audit:messages:retry uses valid segments but is not a real permission.
Assert.That(PermissionId.TryParse("audit:messages:retry", out _), Is.False);
}

[Test]
public void TryParse_rejects_malformed_input()
{
Assert.That(PermissionId.TryParse("error:messages", out _), Is.False);
Assert.That(PermissionId.TryParse("error:messages:view:extra", out _), Is.False);
Assert.That(PermissionId.TryParse("nope:nope:nope", out _), Is.False);
}

[Test]
public void TryParse_is_case_insensitive()
{
Assert.That(PermissionId.TryParse("ERROR:Messages:VIEW", out var permission), Is.True);
Assert.That(permission, Is.EqualTo(new PermissionId(InstanceId.Error, Component.Messages, AccessLevel.View)));
}

[Test]
public void Pattern_matches_expected_permissions()
{
var viewPattern = PermissionPattern.Parse("*:*:view");

Assert.That(viewPattern.Matches(PermissionId.Parse("error:messages:view")), Is.True);
Assert.That(viewPattern.Matches(PermissionId.Parse("error:messages:retry")), Is.False);
}
}
18 changes: 18 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/AccessLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

/// <summary>
/// The action a permission authorizes. <see cref="View"/> is the read-only level; every other value is a
/// write/mutating action. The wire value is the name lowercased (e.g. <see cref="View"/> → <c>view</c>).
/// </summary>
public enum AccessLevel
{
View,
Retry,
Archive,
Unarchive,
Edit,
Manage,
Delete,
Test
}
39 changes: 39 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/Component.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

/// <summary>
/// The functional area a permission applies to, flat across all instances. The wire value is the name
/// lowercased (e.g. <see cref="RecoverabilityGroups"/> → <c>recoverabilitygroups</c>).
/// <para>
/// The set is flat (not nested per instance), so not every <see cref="Component"/> is valid for every
/// <see cref="InstanceId"/>. The error instance uses the plural forms (<see cref="Messages"/>,
/// <see cref="Endpoints"/>, …) while the audit and monitoring instances use the singular forms
/// (<see cref="Message"/>, <see cref="Endpoint"/>, …). Validity of a full
/// <c>instance:component:access</c> triple is enforced by <see cref="PermissionId.TryParse"/> against
/// the known catalogue.
/// </para>
/// </summary>
public enum Component
{
// Error instance (plural).
Messages,
RecoverabilityGroups,
Endpoints,
Heartbeats,
CustomChecks,
Sagas,
EventLog,
Licensing,
Notifications,
Redirects,
Queues,
Throughput,
Connections,

// Audit / Monitoring instances (singular).
Message,
Connection,
Endpoint,
Saga,
License
}
14 changes: 14 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/InstanceId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

/// <summary>
/// The ServiceControl instance a permission belongs to. Each instance is a separate process and
/// namespaces its permissions with this prefix. The wire value is the name lowercased
/// (e.g. <see cref="Error"/> → <c>error</c>).
/// </summary>
public enum InstanceId
{
Error,
Audit,
Monitoring
}
79 changes: 79 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/PermissionId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// A strongly-typed authorization permission in the canonical wire format
/// <c>instance:component:access</c> (e.g. <c>error:messages:view</c>).
/// <para>
/// The hand-authored <c>const string</c> constants in <see cref="Permissions"/> remain the source of
/// truth (they are required by <c>[Authorize(Policy = …)]</c> attributes). <see cref="All"/> is derived
/// from <see cref="Permissions.All"/>, and <see cref="TryParse"/> only accepts a triple that is a member
/// of that catalogue — so a well-typed but non-existent combination such as <c>audit:messages:retry</c>
/// is rejected.
/// </para>
/// </summary>
public readonly record struct PermissionId(InstanceId Instance, Component Component, AccessLevel Access)
{
/// <summary>The complete set of known permissions, parsed from <see cref="Permissions.All"/>.</summary>
public static IReadOnlySet<PermissionId> All { get; } = BuildAll();

/// <summary>The canonical wire representation, <c>instance:component:access</c> (lowercased).</summary>
public override string ToString() =>
$"{Instance.ToString().ToLowerInvariant()}:{Component.ToString().ToLowerInvariant()}:{Access.ToString().ToLowerInvariant()}";

/// <summary>
/// Parses a <c>instance:component:access</c> string. Case-insensitive. Returns <see langword="false"/>
/// for malformed input or for a well-typed triple that is not part of the known catalogue.
/// </summary>
public static bool TryParse(string value, out PermissionId permission) =>
TryParseSegments(value, out permission) && All.Contains(permission);

/// <summary>Parses a <c>instance:component:access</c> string, throwing on an unknown or malformed value.</summary>
public static PermissionId Parse(string value) =>
TryParse(value, out var permission)
? permission
: throw new FormatException($"'{value}' is not a known permission.");

static IReadOnlySet<PermissionId> BuildAll()
{
var set = new HashSet<PermissionId>();
foreach (var value in Permissions.All)
{
if (TryParseSegments(value, out var permission))
{
set.Add(permission);
}
}

return set;
}

// Enum-level parse only; does not validate the triple against the catalogue (used to build it).
static bool TryParseSegments(string value, out PermissionId permission)
{
permission = default;

var segments = value.Split(':');
if (segments.Length != 3)
{
return false;
}

if (TryParseEnum<InstanceId>(segments[0], out var instance)
&& TryParseEnum<Component>(segments[1], out var component)
&& TryParseEnum<AccessLevel>(segments[2], out var access))
{
permission = new PermissionId(instance, component, access);
return true;
}

return false;
}

static bool TryParseEnum<T>(string value, out T result) where T : struct, Enum =>
Enum.TryParse(value, ignoreCase: true, out result) && Enum.IsDefined(result);
}
51 changes: 51 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/PermissionPattern.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System;

/// <summary>
/// A wildcard pattern over the three permission segments, where a <see langword="null"/> segment is the
/// <c>*</c> wildcard that matches any value. For example <c>*:*:view</c> is
/// <c>new PermissionPattern(null, null, AccessLevel.View)</c> and matches every view permission.
/// </summary>
public readonly record struct PermissionPattern(InstanceId? Instance, Component? Component, AccessLevel? Access)
{
/// <summary>Returns <see langword="true"/> if <paramref name="permission"/> matches every non-wildcard segment.</summary>
public bool Matches(PermissionId permission) =>
(Instance is null || Instance == permission.Instance)
&& (Component is null || Component == permission.Component)
&& (Access is null || Access == permission.Access);

/// <summary>
/// Parses a colon-delimited pattern (e.g. <c>*:*:view</c>) where <c>*</c> is a segment wildcard.
/// Throws on a malformed pattern or an unknown segment value.
/// </summary>
public static PermissionPattern Parse(string value)
{
var segments = value.Split(':');
if (segments.Length != 3)
{
throw new FormatException($"'{value}' is not a valid permission pattern (expected instance:component:access).");
}

return new PermissionPattern(
ParseSegment<InstanceId>(segments[0]),
ParseSegment<Component>(segments[1]),
ParseSegment<AccessLevel>(segments[2]));
}

static T? ParseSegment<T>(string value) where T : struct, Enum
{
if (value == "*")
{
return null;
}

if (Enum.TryParse<T>(value, ignoreCase: true, out var result) && Enum.IsDefined(result))
{
return result;
}

throw new FormatException($"'{value}' is not a valid {typeof(T).Name} segment.");
}
}
36 changes: 7 additions & 29 deletions src/ServiceControl.Infrastructure/Auth/RolePermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ public static class RolePermissions
/// <summary>Full-access role: every permission.</summary>
public const string Writer = "writer";

// Source of truth: the wildcard pattern(s) each role grants.
static readonly Dictionary<string, string[]> RolePatterns = new(StringComparer.OrdinalIgnoreCase)
// Source of truth: the wildcard pattern(s) each role grants (null segment = '*').
static readonly Dictionary<string, PermissionPattern[]> RolePatterns = new(StringComparer.OrdinalIgnoreCase)
{
[Reader] = ["*:*:view"],
[Writer] = ["*:*:*"],
[Reader] = [new PermissionPattern(null, null, AccessLevel.View)],
[Writer] = [new PermissionPattern(null, null, null)],
};

// Expanded once against the full permission catalogue: role -> concrete granted permissions.
Expand Down Expand Up @@ -90,34 +90,12 @@ static FrozenDictionary<string, FrozenSet<string>> Expand()

foreach (var (role, patterns) in RolePatterns)
{
expanded[role] = Permissions.All
.Where(permission => patterns.Any(pattern => Matches(pattern, permission)))
expanded[role] = PermissionId.All
.Where(permission => patterns.Any(pattern => pattern.Matches(permission)))
.Select(permission => permission.ToString())
.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;
}
}
Loading