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": "*"
}