Restructure solution layout by module
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
150
src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs
Normal file
150
src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/Registry/StellaOps.Registry.TokenService/Program.cs
Normal file
171
src/Registry/StellaOps.Registry.TokenService/Program.cs
Normal 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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
137
src/Registry/StellaOps.Registry.sln
Normal file
137
src/Registry/StellaOps.Registry.sln
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Registry.TokenService.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user