consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
public interface IScopeAuthorizer
|
||||
{
|
||||
void EnsureScope(HttpContext context, string requiredScope);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user