Files
git.stella-ops.org/src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs
2025-10-28 15:10:40 +02:00

151 lines
4.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.RegularExpressions;
namespace StellaOps.Registry.TokenService;
/// <summary>
/// Evaluates repository access against configured plan rules.
/// </summary>
public sealed class PlanRegistry
{
private readonly IReadOnlyDictionary<string, PlanDescriptor> _plans;
private readonly IReadOnlySet<string> _revokedLicenses;
private readonly string? _defaultPlan;
public PlanRegistry(RegistryTokenServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_plans = options.Plans
.Select(plan => new PlanDescriptor(plan))
.ToDictionary(static plan => plan.Name, StringComparer.OrdinalIgnoreCase);
_revokedLicenses = options.RevokedLicenses.Count == 0
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(options.RevokedLicenses, StringComparer.OrdinalIgnoreCase);
_defaultPlan = options.DefaultPlan;
}
public RegistryAccessDecision Authorize(
ClaimsPrincipal principal,
IReadOnlyList<RegistryAccessRequest> requests)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(requests);
if (requests.Count == 0)
{
return new RegistryAccessDecision(false, "no_scopes_requested");
}
var licenseId = principal.FindFirstValue("stellaops:license")?.Trim();
if (!string.IsNullOrEmpty(licenseId) && _revokedLicenses.Contains(licenseId))
{
return new RegistryAccessDecision(false, "license_revoked");
}
var planName = principal.FindFirstValue("stellaops:plan")?.Trim();
if (string.IsNullOrEmpty(planName))
{
planName = _defaultPlan;
}
if (string.IsNullOrEmpty(planName) || !_plans.TryGetValue(planName, out var descriptor))
{
return new RegistryAccessDecision(false, "plan_unknown");
}
foreach (var request in requests)
{
if (!descriptor.IsRepositoryAllowed(request))
{
return new RegistryAccessDecision(false, "scope_not_permitted");
}
}
return new RegistryAccessDecision(true);
}
private sealed class PlanDescriptor
{
private readonly IReadOnlyList<RepositoryDescriptor> _repositories;
public PlanDescriptor(RegistryTokenServiceOptions.PlanRule source)
{
Name = source.Name;
_repositories = source.Repositories
.Select(rule => new RepositoryDescriptor(rule))
.ToArray();
}
public string Name { get; }
public bool IsRepositoryAllowed(RegistryAccessRequest request)
{
if (!string.Equals(request.Type, "repository", StringComparison.OrdinalIgnoreCase))
{
return false;
}
foreach (var repo in _repositories)
{
if (!repo.Matches(request.Name))
{
continue;
}
if (repo.AllowsActions(request.Actions))
{
return true;
}
}
return false;
}
}
private sealed class RepositoryDescriptor
{
private readonly Regex _pattern;
private readonly IReadOnlySet<string> _allowedActions;
public RepositoryDescriptor(RegistryTokenServiceOptions.RepositoryRule rule)
{
Pattern = rule.Pattern;
_pattern = Compile(rule.Pattern);
_allowedActions = new HashSet<string>(rule.Actions, StringComparer.OrdinalIgnoreCase);
}
public string Pattern { get; }
public bool Matches(string repository)
{
return _pattern.IsMatch(repository);
}
public bool AllowsActions(IReadOnlyList<string> actions)
{
foreach (var action in actions)
{
if (!_allowedActions.Contains(action))
{
return false;
}
}
return true;
}
private static Regex Compile(string pattern)
{
var escaped = Regex.Escape(pattern);
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
}
}
}