consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace StellaOps.Scheduler.WebService.Auth;
/// <summary>
/// Development/test-only authentication handler that authenticates requests
/// carrying header-based dev credentials (X-Tenant-Id + X-Scopes).
/// When neither header is present the handler returns NoResult so ASP.NET
/// Core issues a 401 challenge, matching production auth behavior.
/// </summary>
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string TenantHeader = "X-Tenant-Id";
private static readonly string[] ScopeHeaders = ["X-StellaOps-Scopes", "X-Scopes"];
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Require at least the tenant header for dev-auth to engage.
// Without it, return NoResult so the pipeline issues a 401 challenge.
if (!Request.Headers.TryGetValue(TenantHeader, out var tenantValues)
|| string.IsNullOrWhiteSpace(tenantValues.ToString()))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var tenantId = tenantValues.ToString().Trim();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "anonymous"),
new("stellaops:tenant", tenantId),
// Coarse OIDC-style scopes so ASP.NET Core authorization policies pass.
// Fine-grained scope enforcement happens inside endpoint handlers
// via IScopeAuthorizer which reads the X-Scopes / X-StellaOps-Scopes header directly.
new("scope",
"scheduler:read scheduler:operate scheduler:admin " +
"graph:read graph:write policy:simulate"),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class ClaimsTenantContextAccessor : ITenantContextAccessor
{
public TenantContext GetTenant(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var principal = context.User ?? throw new UnauthorizedAccessException("Authentication required.");
if (principal.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("Authentication required.");
}
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Authenticated principal is missing required tenant claim.");
}
return new TenantContext(tenant.Trim());
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer
{
private static readonly string[] ScopeHeaders = ["X-StellaOps-Scopes", "X-Scopes"];
public void EnsureScope(HttpContext context, string requiredScope)
{
Microsoft.Extensions.Primitives.StringValues values = default;
bool found = false;
foreach (var header in ScopeHeaders)
{
if (context.Request.Headers.TryGetValue(header, out values))
{
found = true;
break;
}
}
if (!found)
{
throw new UnauthorizedAccessException($"Missing required scope header (accepted: {string.Join(", ", ScopeHeaders)}).");
}
var scopeBuffer = string.Join(' ', values.ToArray());
if (string.IsNullOrWhiteSpace(scopeBuffer))
{
throw new UnauthorizedAccessException("Scope header cannot be empty.");
}
var scopes = scopeBuffer
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (scopes.Contains(requiredScope))
{
return;
}
// Hierarchical match: fine-grained scope "scheduler.runs.read" is satisfied
// by OIDC coarse-grained scope "scheduler:read" or "scheduler:admin".
// Format: "{service}.{resource}.{action}" -> check "{service}:{action}" and "{service}:admin"
var dotParts = requiredScope.Split('.');
if (dotParts.Length >= 2)
{
var service = dotParts[0];
var action = dotParts[^1];
if (scopes.Contains($"{service}:{action}") || scopes.Contains($"{service}:admin"))
{
return;
}
// Also check "operate" scope for write/manage actions
if (action is "write" or "manage" or "preview" && scopes.Contains($"{service}:operate"))
{
return;
}
}
throw new InvalidOperationException($"Missing required scope '{requiredScope}'.");
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class HeaderTenantContextAccessor : ITenantContextAccessor
{
private const string TenantHeader = "X-Tenant-Id";
public TenantContext GetTenant(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(TenantHeader, out var values))
{
throw new UnauthorizedAccessException($"Missing required header '{TenantHeader}'.");
}
var tenantId = values.ToString().Trim();
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new UnauthorizedAccessException($"Header '{TenantHeader}' cannot be empty.");
}
return new TenantContext(tenantId);
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
public interface IScopeAuthorizer
{
void EnsureScope(HttpContext context, string requiredScope);
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
public interface ITenantContextAccessor
{
TenantContext GetTenant(HttpContext context);
}
public sealed record TenantContext(string TenantId);

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class TokenScopeAuthorizer : IScopeAuthorizer
{
public void EnsureScope(HttpContext context, string requiredScope)
{
ArgumentNullException.ThrowIfNull(context);
if (string.IsNullOrWhiteSpace(requiredScope))
{
return;
}
var principal = context.User ?? throw new UnauthorizedAccessException("Authentication required.");
if (principal.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("Authentication required.");
}
var normalizedRequired = StellaOpsScopes.Normalize(requiredScope) ?? requiredScope.Trim().ToLowerInvariant();
if (!HasScope(principal, normalizedRequired))
{
throw new InvalidOperationException($"Missing required scope '{normalizedRequired}'.");
}
}
private static bool HasScope(ClaimsPrincipal principal, string requiredScope)
{
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null && string.Equals(normalized, requiredScope, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}