Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,34 @@
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Registry.TokenService.Observability;
public sealed class RegistryTokenMetrics : IDisposable
{
public const string MeterName = "StellaOps.Registry.TokenService";
private readonly Meter _meter;
private bool _disposed;
public RegistryTokenMetrics()
{
_meter = new Meter(MeterName);
TokensIssued = _meter.CreateCounter<long>("registry_token_issued_total", unit: "tokens", description: "Total tokens issued grouped by plan.");
TokensRejected = _meter.CreateCounter<long>("registry_token_rejected_total", unit: "tokens", description: "Total token requests rejected grouped by reason.");
}
public Counter<long> TokensIssued { get; }
public Counter<long> TokensRejected { get; }
public void Dispose()
{
if (_disposed)
{
return;
}
_meter.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,150 @@
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);
}
}
}

View File

@@ -0,0 +1,171 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using OpenTelemetry.Instrumentation.AspNetCore;
using OpenTelemetry.Instrumentation.Runtime;
using OpenTelemetry.Metrics;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Registry.TokenService;
using StellaOps.Registry.TokenService.Observability;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "REGISTRY_TOKEN_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddYamlFile("../etc/registry-token.yaml", optional: true, reloadOnChange: true);
};
});
var bootstrapOptions = builder.Configuration.BindOptions<RegistryTokenServiceOptions>(
RegistryTokenServiceOptions.SectionName,
(opts, _) => opts.Validate());
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.Services.AddOptions<RegistryTokenServiceOptions>()
.Bind(builder.Configuration.GetSection(RegistryTokenServiceOptions.SectionName))
.PostConfigure(options => options.Validate())
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<RegistryTokenMetrics>();
builder.Services.AddSingleton<PlanRegistry>(sp =>
{
var options = sp.GetRequiredService<IOptions<RegistryTokenServiceOptions>>().Value;
return new PlanRegistry(options);
});
builder.Services.AddSingleton<RegistryTokenIssuer>();
builder.Services.AddHealthChecks().AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
builder.Services.AddOpenTelemetry()
.WithMetrics(metricsBuilder =>
{
metricsBuilder.AddMeter(RegistryTokenMetrics.MeterName);
metricsBuilder.AddAspNetCoreInstrumentation();
metricsBuilder.AddRuntimeInstrumentation();
});
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
});
builder.Services.AddAuthorization(options =>
{
var scopes = bootstrapOptions.Authority.RequiredScopes.Count == 0
? new[] { "registry.token.issue" }
: bootstrapOptions.Authority.RequiredScopes.ToArray();
options.AddPolicy("registry.token.issue", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
});
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/token", (
HttpContext context,
[FromServices] IOptions<RegistryTokenServiceOptions> options,
[FromServices] RegistryTokenIssuer issuer) =>
{
var serviceOptions = options.Value;
var service = context.Request.Query["service"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(service))
{
return Results.Problem(
detail: "The 'service' query parameter is required.",
statusCode: StatusCodes.Status400BadRequest);
}
if (serviceOptions.Registry.AllowedServices.Count > 0 &&
!serviceOptions.Registry.AllowedServices.Contains(service, StringComparer.OrdinalIgnoreCase))
{
return Results.Problem(
detail: "The requested registry service is not permitted for this installation.",
statusCode: StatusCodes.Status403Forbidden);
}
IReadOnlyList<RegistryAccessRequest> accessRequests;
try
{
accessRequests = RegistryScopeParser.Parse(context.Request.Query);
}
catch (InvalidScopeException ex)
{
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status400BadRequest);
}
if (accessRequests.Count == 0)
{
return Results.Problem(
detail: "At least one scope must be requested.",
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var response = issuer.IssueToken(context.User, service, accessRequests);
return Results.Json(new
{
token = response.Token,
expires_in = response.ExpiresIn,
issued_at = response.IssuedAt.UtcDateTime.ToString("O"),
issued_token_type = "urn:ietf:params:oauth:token-type:access_token"
});
}
catch (RegistryTokenException ex)
{
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status403Forbidden);
}
})
.WithName("GetRegistryToken")
.RequireAuthorization("registry.token.issue")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden);
app.Run();

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace StellaOps.Registry.TokenService;
/// <summary>
/// Represents a scope access request parsed from the <c>scope</c> query parameter.
/// </summary>
public sealed record RegistryAccessRequest(string Type, string Name, IReadOnlyList<string> Actions);
/// <summary>
/// Authorization decision.
/// </summary>
public sealed record RegistryAccessDecision(bool Allowed, string? FailureReason = null);

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Registry.TokenService;
public static class RegistryScopeParser
{
public static IReadOnlyList<RegistryAccessRequest> Parse(IQueryCollection query)
{
ArgumentNullException.ThrowIfNull(query);
var scopes = new List<string>();
if (query.TryGetValue("scope", out var scopeValues))
{
foreach (var scope in scopeValues)
{
if (string.IsNullOrWhiteSpace(scope))
{
continue;
}
// Support space-delimited scopes per OAuth2 spec
foreach (var component in scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(component);
}
}
}
var requests = new List<RegistryAccessRequest>(scopes.Count);
foreach (var scope in scopes)
{
var request = ParseScope(scope);
requests.Add(request);
}
return requests;
}
private static RegistryAccessRequest ParseScope(string scope)
{
var segments = scope.Split(':', StringSplitOptions.TrimEntries);
if (segments.Length < 1)
{
throw new InvalidScopeException(scope, "scope missing resource type");
}
var type = segments[0];
if (!string.Equals(type, "repository", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidScopeException(scope, $"unsupported resource type '{type}'");
}
if (segments.Length < 2 || string.IsNullOrWhiteSpace(segments[1]))
{
throw new InvalidScopeException(scope, "repository scope missing name");
}
var name = segments[1];
var actions = segments.Length >= 3 && !string.IsNullOrWhiteSpace(segments[2])
? segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>();
if (actions.Length == 0)
{
actions = new[] { "pull" };
}
var normalized = actions
.Select(action => action.ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new RegistryAccessRequest(type.ToLowerInvariant(), name, normalized);
}
}
public sealed class InvalidScopeException : Exception
{
public InvalidScopeException(string scope, string reason)
: base($"Invalid scope '{scope}': {reason}")
{
Scope = scope;
Reason = reason;
}
public string Scope { get; }
public string Reason { get; }
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Registry.TokenService.Observability;
using StellaOps.Registry.TokenService.Security;
namespace StellaOps.Registry.TokenService;
public sealed class RegistryTokenIssuer
{
private readonly RegistryTokenServiceOptions _options;
private readonly PlanRegistry _planRegistry;
private readonly RegistryTokenMetrics _metrics;
private readonly SigningCredentials _signingCredentials;
private readonly JwtSecurityTokenHandler _tokenHandler = new();
private readonly TimeProvider _timeProvider;
public RegistryTokenIssuer(
IOptions<RegistryTokenServiceOptions> options,
PlanRegistry planRegistry,
RegistryTokenMetrics metrics,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(planRegistry);
ArgumentNullException.ThrowIfNull(metrics);
ArgumentNullException.ThrowIfNull(timeProvider);
_options = options.Value;
_planRegistry = planRegistry;
_metrics = metrics;
_timeProvider = timeProvider;
_signingCredentials = SigningKeyLoader.Load(_options.Signing);
}
public RegistryTokenResponse IssueToken(
ClaimsPrincipal principal,
string service,
IReadOnlyList<RegistryAccessRequest> requests)
{
var decision = _planRegistry.Authorize(principal, requests);
if (!decision.Allowed)
{
_metrics.TokensRejected.Add(1, new KeyValuePair<string, object?>("reason", decision.FailureReason ?? "denied"));
throw new RegistryTokenException(decision.FailureReason ?? "denied");
}
var now = _timeProvider.GetUtcNow();
var expires = now + _options.Signing.Lifetime;
var subject = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue("client_id")
?? principal.FindFirstValue("sub")
?? "anonymous";
var payload = new JwtPayload(
issuer: _options.Signing.Issuer,
audience: _options.Signing.Audience ?? service,
claims: null,
notBefore: now.UtcDateTime,
expires: expires.UtcDateTime,
issuedAt: now.UtcDateTime)
{
{ JwtRegisteredClaimNames.Sub, subject },
{ JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("n") },
{ "service", service },
{ "access", BuildAccessClaim(requests) }
};
var licenseId = principal.FindFirstValue("stellaops:license");
if (!string.IsNullOrWhiteSpace(licenseId))
{
payload["stellaops:license"] = licenseId;
}
var token = new JwtSecurityToken(new JwtHeader(_signingCredentials), payload);
var serialized = _tokenHandler.WriteToken(token);
var plan = principal.FindFirstValue("stellaops:plan") ?? _options.DefaultPlan ?? "unknown";
_metrics.TokensIssued.Add(1, new KeyValuePair<string, object?>("plan", plan));
return new RegistryTokenResponse(
serialized,
(int)_options.Signing.Lifetime.TotalSeconds,
now);
}
private static object BuildAccessClaim(IReadOnlyList<RegistryAccessRequest> requests)
{
return requests
.Select(request => new Dictionary<string, object>
{
["type"] = request.Type,
["name"] = request.Name,
["actions"] = request.Actions
})
.ToArray();
}
}
public sealed class RegistryTokenResponse
{
public RegistryTokenResponse(string token, int expiresInSeconds, DateTimeOffset issuedAt)
{
Token = token;
ExpiresIn = expiresInSeconds;
IssuedAt = issuedAt;
}
public string Token { get; }
public int ExpiresIn { get; }
public DateTimeOffset IssuedAt { get; }
}
public sealed class RegistryTokenException : Exception
{
public RegistryTokenException(string reason)
: base($"Token request denied: {reason}")
{
Reason = reason;
}
public string Reason { get; }
}

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace StellaOps.Registry.TokenService;
/// <summary>
/// Strongly typed options for the registry token service.
/// </summary>
public sealed class RegistryTokenServiceOptions
{
public const string SectionName = "RegistryTokenService";
/// <summary>
/// Authority validation options.
/// </summary>
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
/// JWT signing options.
/// </summary>
public SigningOptions Signing { get; set; } = new();
/// <summary>
/// Registry-scoped settings.
/// </summary>
public RegistryOptions Registry { get; set; } = new();
/// <summary>
/// Plan catalogue.
/// </summary>
public IList<PlanRule> Plans { get; set; } = new List<PlanRule>();
/// <summary>
/// Identifiers that are revoked (license IDs or customer IDs).
/// </summary>
public IList<string> RevokedLicenses { get; set; } = new List<string>();
/// <summary>
/// Optional explicit default plan when no plan claim is supplied.
/// </summary>
public string? DefaultPlan { get; set; }
public void Validate()
{
Authority.Validate();
Signing.Validate();
Registry.Validate();
if (Plans.Count == 0)
{
throw new InvalidOperationException("At least one plan rule must be configured.");
}
foreach (var plan in Plans)
{
plan.Validate();
}
NormalizeList(RevokedLicenses, toLower: true);
if (!string.IsNullOrWhiteSpace(DefaultPlan))
{
var normalized = DefaultPlan.Trim();
if (!Plans.Any(plan => string.Equals(plan.Name, normalized, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Default plan '{normalized}' is not present in the plan catalogue.");
}
DefaultPlan = normalized;
}
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var normalized = value.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
if (!seen.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
public sealed class AuthorityOptions
{
/// <summary>
/// Issuer/authority URL (e.g. https://authority.stella.internal).
/// </summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Optional explicit metadata (JWKS) endpoint.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Whether HTTPS metadata is required (disabled for dev loops).
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Audiences that resource server accepts.
/// </summary>
public IList<string> Audiences { get; set; } = new List<string>();
/// <summary>
/// Scopes required to hit the token endpoint.
/// </summary>
public IList<string> RequiredScopes { get; set; } = new List<string> { "registry.token.issue" };
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Authority issuer must be configured.");
}
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (RequireHttpsMetadata &&
!uri.IsLoopback &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Authority issuer must use HTTPS when RequireHttpsMetadata is true.");
}
NormalizeList(Audiences, toLower: false);
NormalizeList(RequiredScopes, toLower: true);
}
}
public sealed class SigningOptions
{
/// <summary>
/// Issuer for generated registry tokens.
/// </summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Optional audience override. Defaults to the requested registry service.
/// </summary>
public string? Audience { get; set; }
/// <summary>
/// Path to an RSA private key (PEM or PFX).
/// </summary>
public string KeyPath { get; set; } = string.Empty;
/// <summary>
/// Optional password when loading a PFX.
/// </summary>
public string? KeyPassword { get; set; }
/// <summary>
/// Optional key identifier (kid) appended to the JWT header.
/// </summary>
public string? KeyId { get; set; }
/// <summary>
/// Token lifetime.
/// </summary>
public TimeSpan Lifetime { get; set; } = TimeSpan.FromMinutes(5);
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Signing.Issuer must be provided.");
}
if (Lifetime <= TimeSpan.Zero || Lifetime > TimeSpan.FromHours(1))
{
throw new InvalidOperationException("Signing.Lifetime must be between 1 second and 1 hour.");
}
if (string.IsNullOrWhiteSpace(KeyPath))
{
throw new InvalidOperationException("Signing.KeyPath must be configured.");
}
var file = KeyPath.Trim();
if (!Path.IsPathRooted(file))
{
file = Path.GetFullPath(file);
}
if (!File.Exists(file))
{
throw new InvalidOperationException($"Signing.KeyPath '{file}' does not exist.");
}
KeyPath = file;
if (!string.IsNullOrWhiteSpace(KeyId))
{
KeyId = KeyId.Trim();
}
}
}
public sealed class RegistryOptions
{
/// <summary>
/// Registry service realm (matches Docker registry configuration).
/// </summary>
public string Realm { get; set; } = string.Empty;
/// <summary>
/// Allowed service identifiers. Empty list permits any service.
/// </summary>
public IList<string> AllowedServices { get; set; } = new List<string>();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Realm))
{
throw new InvalidOperationException("Registry.Realm must be provided.");
}
if (!Uri.TryCreate(Realm.Trim(), UriKind.Absolute, out _))
{
throw new InvalidOperationException("Registry.Realm must be an absolute URI.");
}
NormalizeList(AllowedServices, toLower: false);
}
}
public sealed class PlanRule
{
/// <summary>
/// Plan identifier (case-insensitive).
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Repository rules associated to the plan.
/// </summary>
public IList<RepositoryRule> Repositories { get; set; } = new List<RepositoryRule>();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new InvalidOperationException("Plan name cannot be empty.");
}
Name = Name.Trim();
if (Repositories.Count == 0)
{
throw new InvalidOperationException($"Plan '{Name}' must specify at least one repository rule.");
}
foreach (var repo in Repositories)
{
repo.Validate(Name);
}
}
}
public sealed class RepositoryRule
{
/// <summary>
/// Repository pattern (supports '*' wildcard).
/// </summary>
public string Pattern { get; set; } = string.Empty;
/// <summary>
/// Allowed actions (pull/push/delete, etc.)
/// </summary>
public IList<string> Actions { get; set; } = new List<string> { "pull" };
public void Validate(string planName)
{
if (string.IsNullOrWhiteSpace(Pattern))
{
throw new InvalidOperationException($"Plan '{planName}' contains a repository rule with an empty pattern.");
}
Pattern = Pattern.Trim();
if (Pattern.Contains(' ', StringComparison.Ordinal))
{
throw new InvalidOperationException($"Plan '{planName}' repository pattern '{Pattern}' may not contain spaces.");
}
if (Actions.Count == 0)
{
throw new InvalidOperationException($"Plan '{planName}' repository '{Pattern}' must define allowed actions.");
}
NormalizeList(Actions, toLower: true);
}
}
}

View File

@@ -0,0 +1,66 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Registry.TokenService.Security;
internal static class SigningKeyLoader
{
public static SigningCredentials Load(RegistryTokenServiceOptions.SigningOptions options)
{
ArgumentNullException.ThrowIfNull(options);
SecurityKey key;
var extension = Path.GetExtension(options.KeyPath);
if (string.Equals(extension, ".pfx", StringComparison.OrdinalIgnoreCase))
{
key = LoadFromPfx(options.KeyPath, options.KeyPassword);
}
else
{
key = LoadFromPem(options.KeyPath);
}
var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256)
{
CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = true }
};
if (!string.IsNullOrWhiteSpace(options.KeyId))
{
credentials.Key.KeyId = options.KeyId;
}
return credentials;
}
private static SecurityKey LoadFromPfx(string path, string? password)
{
using var cert = X509CertificateLoader.LoadPkcs12FromFile(path, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
if (!cert.HasPrivateKey)
{
throw new InvalidOperationException($"Certificate '{path}' does not contain a private key.");
}
if (cert.GetRSAPrivateKey() is not RSA rsa)
{
throw new InvalidOperationException($"Certificate '{path}' does not contain an RSA private key.");
}
var parameters = rsa.ExportParameters(true);
rsa.Dispose();
return new RsaSecurityKey(parameters) { KeyId = cert.Thumbprint };
}
private static SecurityKey LoadFromPem(string path)
{
using var rsa = RSA.Create();
var pem = File.ReadAllText(path);
rsa.ImportFromPem(pem);
return new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: true));
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,137 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Registry.TokenService", "StellaOps.Registry.TokenService\StellaOps.Registry.TokenService.csproj", "{47219E8C-6EF9-4F09-88D0-28E7525824F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{5B2C944F-C02D-444E-BF69-6FF06E8BB165}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{538BEB07-55EB-4AAD-B323-D49984F152F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Registry.TokenService.Tests", "__Tests\StellaOps.Registry.TokenService.Tests\StellaOps.Registry.TokenService.Tests.csproj", "{C34D56B3-8B7A-4AF0-8279-80155527235B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|x64.ActiveCfg = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|x64.Build.0 = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|x86.ActiveCfg = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Debug|x86.Build.0 = Debug|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|Any CPU.Build.0 = Release|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|x64.ActiveCfg = Release|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|x64.Build.0 = Release|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|x86.ActiveCfg = Release|Any CPU
{47219E8C-6EF9-4F09-88D0-28E7525824F6}.Release|x86.Build.0 = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|x64.ActiveCfg = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|x64.Build.0 = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|x86.ActiveCfg = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Debug|x86.Build.0 = Debug|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|Any CPU.Build.0 = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|x64.ActiveCfg = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|x64.Build.0 = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|x86.ActiveCfg = Release|Any CPU
{5B2C944F-C02D-444E-BF69-6FF06E8BB165}.Release|x86.Build.0 = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|Any CPU.Build.0 = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|x64.ActiveCfg = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|x64.Build.0 = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|x86.ActiveCfg = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Debug|x86.Build.0 = Debug|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|Any CPU.ActiveCfg = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|Any CPU.Build.0 = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|x64.ActiveCfg = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|x64.Build.0 = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|x86.ActiveCfg = Release|Any CPU
{75CE45B4-ACA9-4E96-A7C8-99F05A6B8090}.Release|x86.Build.0 = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|x64.ActiveCfg = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|x64.Build.0 = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|x86.ActiveCfg = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Debug|x86.Build.0 = Debug|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|Any CPU.Build.0 = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|x64.ActiveCfg = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|x64.Build.0 = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|x86.ActiveCfg = Release|Any CPU
{84F1A536-BA7B-4FF6-82C1-EC324B3BD158}.Release|x86.Build.0 = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|x64.ActiveCfg = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|x64.Build.0 = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|x86.ActiveCfg = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Debug|x86.Build.0 = Debug|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|Any CPU.Build.0 = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|x64.ActiveCfg = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|x64.Build.0 = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|x86.ActiveCfg = Release|Any CPU
{538BEB07-55EB-4AAD-B323-D49984F152F6}.Release|x86.Build.0 = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|Any CPU.Build.0 = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|x64.ActiveCfg = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|x64.Build.0 = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|x86.ActiveCfg = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Debug|x86.Build.0 = Debug|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|Any CPU.ActiveCfg = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|Any CPU.Build.0 = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|x64.ActiveCfg = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|x64.Build.0 = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|x86.ActiveCfg = Release|Any CPU
{41DF0C8A-D826-4398-95F7-7FEDFEFE9053}.Release|x86.Build.0 = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|x64.ActiveCfg = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|x64.Build.0 = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|x86.ActiveCfg = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Debug|x86.Build.0 = Debug|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|Any CPU.Build.0 = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|x64.ActiveCfg = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|x64.Build.0 = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|x86.ActiveCfg = Release|Any CPU
{F97C3CD8-B89D-4E4D-815C-4D799F65A78A}.Release|x86.Build.0 = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|x64.ActiveCfg = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|x64.Build.0 = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|x86.ActiveCfg = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Debug|x86.Build.0 = Debug|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|Any CPU.Build.0 = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|x64.ActiveCfg = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|x64.Build.0 = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|x86.ActiveCfg = Release|Any CPU
{C34D56B3-8B7A-4AF0-8279-80155527235B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C34D56B3-8B7A-4AF0-8279-80155527235B} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,109 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using StellaOps.Registry.TokenService;
namespace StellaOps.Registry.TokenService.Tests;
public sealed class PlanRegistryTests
{
private static RegistryTokenServiceOptions CreateOptions()
{
return new RegistryTokenServiceOptions
{
Authority = new RegistryTokenServiceOptions.AuthorityOptions
{
Issuer = "https://authority.localhost",
RequireHttpsMetadata = false,
},
Signing = new RegistryTokenServiceOptions.SigningOptions
{
Issuer = "https://registry.localhost/token",
KeyPath = Path.GetTempFileName(),
},
Registry = new RegistryTokenServiceOptions.RegistryOptions
{
Realm = "https://registry.localhost/v2/token"
},
Plans =
{
new RegistryTokenServiceOptions.PlanRule
{
Name = "community",
Repositories =
{
new RegistryTokenServiceOptions.RepositoryRule
{
Pattern = "stella-ops/public/*",
Actions = new [] { "pull" }
}
}
},
new RegistryTokenServiceOptions.PlanRule
{
Name = "enterprise",
Repositories =
{
new RegistryTokenServiceOptions.RepositoryRule
{
Pattern = "stella-ops/public/*",
Actions = new [] { "pull" }
},
new RegistryTokenServiceOptions.RepositoryRule
{
Pattern = "stella-ops/enterprise/*",
Actions = new [] { "pull", "push" }
}
}
}
}
};
}
[Fact]
public void Authorize_AllowsMatchingPlan()
{
var options = CreateOptions();
options.Signing.Validate();
options.Registry.Validate();
foreach (var plan in options.Plans)
{
plan.Validate();
}
var registry = new PlanRegistry(options);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("stellaops:plan", "enterprise")
}, "test"));
var decision = registry.Authorize(principal, new[]
{
new RegistryAccessRequest("repository", "stella-ops/enterprise/cache", new [] { "pull" })
});
Assert.True(decision.Allowed);
}
[Fact]
public void Authorize_DeniesUnknownPlan()
{
var options = CreateOptions();
options.Signing.Validate();
options.Registry.Validate();
foreach (var plan in options.Plans)
{
plan.Validate();
}
var registry = new PlanRegistry(options);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { }, "test"));
var decision = registry.Authorize(principal, new[]
{
new RegistryAccessRequest("repository", "stella-ops/enterprise/cache", new [] { "pull" })
});
Assert.False(decision.Allowed);
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Registry.TokenService;
namespace StellaOps.Registry.TokenService.Tests;
public sealed class RegistryScopeParserTests
{
[Fact]
public void Parse_SingleScope_DefaultsPull()
{
var query = new QueryCollection(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["scope"] = "repository:stella-ops/public/base"
});
var result = RegistryScopeParser.Parse(query);
Assert.Single(result);
Assert.Equal("repository", result[0].Type);
Assert.Equal("stella-ops/public/base", result[0].Name);
Assert.Equal(new[] { "pull" }, result[0].Actions);
}
[Fact]
public void Parse_MultipleScopes()
{
var query = new QueryCollection(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["scope"] = new[] { "repository:stella/public/api:pull,push", "repository:stella/private/api:pull" }
});
var result = RegistryScopeParser.Parse(query);
Assert.Equal(2, result.Count);
Assert.Equal(new[] { "pull", "push" }, result[0].Actions);
Assert.Equal(new[] { "pull" }, result[1].Actions);
}
}

View File

@@ -0,0 +1,110 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using StellaOps.Registry.TokenService;
using StellaOps.Registry.TokenService.Observability;
namespace StellaOps.Registry.TokenService.Tests;
public sealed class RegistryTokenIssuerTests : IDisposable
{
private readonly List<string> _tempFiles = new();
[Fact]
public void IssueToken_GeneratesJwtWithAccessClaim()
{
var pemPath = CreatePemKey();
var options = new RegistryTokenServiceOptions
{
Authority = new RegistryTokenServiceOptions.AuthorityOptions
{
Issuer = "https://authority.localhost",
RequireHttpsMetadata = false,
},
Signing = new RegistryTokenServiceOptions.SigningOptions
{
Issuer = "https://registry.localhost/token",
KeyPath = pemPath,
Lifetime = TimeSpan.FromMinutes(5)
},
Registry = new RegistryTokenServiceOptions.RegistryOptions
{
Realm = "https://registry.localhost/v2/token"
},
Plans =
{
new RegistryTokenServiceOptions.PlanRule
{
Name = "community",
Repositories =
{
new RegistryTokenServiceOptions.RepositoryRule
{
Pattern = "stella-ops/public/*",
Actions = new [] { "pull" }
}
}
}
}
};
options.Validate();
var issuer = new RegistryTokenIssuer(
Options.Create(options),
new PlanRegistry(options),
new RegistryTokenMetrics(),
TimeProvider.System);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("sub", "client-1"),
new Claim("stellaops:plan", "community")
}, "test"));
var accessRequests = new[]
{
new RegistryAccessRequest("repository", "stella-ops/public/base", new [] { "pull" })
};
var response = issuer.IssueToken(principal, "registry.localhost", accessRequests);
Assert.NotEmpty(response.Token);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(response.Token);
Assert.Equal("https://registry.localhost/token", jwt.Issuer);
Assert.True(jwt.Payload.TryGetValue("access", out var access));
Assert.NotNull(access);
}
private string CreatePemKey()
{
using var rsa = RSA.Create(2048);
var builder = new StringWriter();
builder.WriteLine("-----BEGIN PRIVATE KEY-----");
builder.WriteLine(Convert.ToBase64String(rsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks));
builder.WriteLine("-----END PRIVATE KEY-----");
var path = Path.GetTempFileName();
File.WriteAllText(path, builder.ToString());
_tempFiles.Add(path);
return path;
}
public void Dispose()
{
foreach (var file in _tempFiles)
{
try
{
File.Delete(file);
}
catch
{
// ignore
}
}
}
}

View File

@@ -0,0 +1,29 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Registry.TokenService.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}