up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,34 +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;
}
}
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

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

@@ -1,8 +1,8 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
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;
@@ -16,48 +16,48 @@ using StellaOps.Configuration;
using StellaOps.Telemetry.Core;
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());
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.AddAirGapEgressPolicy(builder.Configuration);
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
@@ -72,111 +72,111 @@ builder.Services.AddStellaOpsTelemetry(
tracerBuilder.AddAspNetCoreInstrumentation();
tracerBuilder.AddHttpClientInstrumentation();
});
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();
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();
using StellaOps.AirGap.Policy;

View File

@@ -1,13 +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);
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

@@ -1,93 +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; }
}
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

@@ -1,129 +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; }
}
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

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

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

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

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

@@ -1,110 +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
}
}
}
}
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

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