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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user