Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
|
||||
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
|
||||
|
||||
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
|
||||
- Network bypass mask evaluation for on-host automation.
|
||||
- Consistent `ProblemDetails` responses and policy helpers shared with Concelier/Backend services.
|
||||
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
|
||||
- `AddObservabilityResourcePolicies` helper to register timeline, evidence, export, and observability scope policies.
|
||||
- Network bypass mask evaluation for on-host automation.
|
||||
- Consistent `ProblemDetails` responses and policy helpers shared with Concelier/Backend services.
|
||||
- Structured audit emission (`authority.resource.authorize`) capturing granted scopes, tenant, and trace identifiers.
|
||||
|
||||
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.1.25451.107" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
|
||||
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
|
||||
@@ -44,4 +46,4 @@
|
||||
<_Parameter1>StellaOps.Auth.ServerIntegration.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policies for StellaOps observability and evidence resource servers.
|
||||
/// </summary>
|
||||
public static class StellaOpsResourceServerPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Observability dashboards/read-only access policy name.
|
||||
/// </summary>
|
||||
public const string ObservabilityRead = StellaOpsScopes.ObservabilityRead;
|
||||
|
||||
/// <summary>
|
||||
/// Observability incident activation policy name.
|
||||
/// </summary>
|
||||
public const string ObservabilityIncident = StellaOpsScopes.ObservabilityIncident;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline read policy name.
|
||||
/// </summary>
|
||||
public const string TimelineRead = StellaOpsScopes.TimelineRead;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline write policy name.
|
||||
/// </summary>
|
||||
public const string TimelineWrite = StellaOpsScopes.TimelineWrite;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence create policy name.
|
||||
/// </summary>
|
||||
public const string EvidenceCreate = StellaOpsScopes.EvidenceCreate;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence read policy name.
|
||||
/// </summary>
|
||||
public const string EvidenceRead = StellaOpsScopes.EvidenceRead;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence hold policy name.
|
||||
/// </summary>
|
||||
public const string EvidenceHold = StellaOpsScopes.EvidenceHold;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation read policy name.
|
||||
/// </summary>
|
||||
public const string AttestRead = StellaOpsScopes.AttestRead;
|
||||
|
||||
/// <summary>
|
||||
/// Export viewer policy name.
|
||||
/// </summary>
|
||||
public const string ExportViewer = StellaOpsScopes.ExportViewer;
|
||||
|
||||
/// <summary>
|
||||
/// Export operator policy name.
|
||||
/// </summary>
|
||||
public const string ExportOperator = StellaOpsScopes.ExportOperator;
|
||||
|
||||
/// <summary>
|
||||
/// Export admin policy name.
|
||||
/// </summary>
|
||||
public const string ExportAdmin = StellaOpsScopes.ExportAdmin;
|
||||
|
||||
/// <summary>
|
||||
/// Registers all observability, timeline, evidence, attestation, and export authorization policies.
|
||||
/// </summary>
|
||||
public static void AddObservabilityResourcePolicies(this AuthorizationOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.AddStellaOpsScopePolicy(ObservabilityRead, StellaOpsScopes.ObservabilityRead);
|
||||
options.AddStellaOpsScopePolicy(ObservabilityIncident, StellaOpsScopes.ObservabilityIncident);
|
||||
options.AddStellaOpsScopePolicy(TimelineRead, StellaOpsScopes.TimelineRead);
|
||||
options.AddStellaOpsScopePolicy(TimelineWrite, StellaOpsScopes.TimelineWrite);
|
||||
options.AddStellaOpsScopePolicy(EvidenceCreate, StellaOpsScopes.EvidenceCreate);
|
||||
options.AddStellaOpsScopePolicy(EvidenceRead, StellaOpsScopes.EvidenceRead);
|
||||
options.AddStellaOpsScopePolicy(EvidenceHold, StellaOpsScopes.EvidenceHold);
|
||||
options.AddStellaOpsScopePolicy(AttestRead, StellaOpsScopes.AttestRead);
|
||||
options.AddStellaOpsScopePolicy(ExportViewer, StellaOpsScopes.ExportViewer);
|
||||
options.AddStellaOpsScopePolicy(ExportOperator, StellaOpsScopes.ExportOperator);
|
||||
options.AddStellaOpsScopePolicy(ExportAdmin, StellaOpsScopes.ExportAdmin);
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,757 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="StellaOpsScopeRequirement"/> evaluation.
|
||||
/// </summary>
|
||||
internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<StellaOpsScopeRequirement>
|
||||
{
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly StellaOpsBypassEvaluator bypassEvaluator;
|
||||
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
|
||||
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
|
||||
|
||||
public StellaOpsScopeAuthorizationHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
StellaOpsBypassEvaluator bypassEvaluator,
|
||||
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
|
||||
ILogger<StellaOpsScopeAuthorizationHandler> logger)
|
||||
{
|
||||
this.httpContextAccessor = httpContextAccessor;
|
||||
this.bypassEvaluator = bypassEvaluator;
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
StellaOpsScopeRequirement requirement)
|
||||
{
|
||||
var resourceOptions = optionsMonitor.CurrentValue;
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes);
|
||||
HashSet<string>? userScopes = null;
|
||||
|
||||
if (context.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
userScopes = ExtractScopes(context.User);
|
||||
|
||||
foreach (var scope in combinedScopes)
|
||||
{
|
||||
if (!userScopes.Contains(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TenantAllowed(context.User, resourceOptions, out var normalizedTenant))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var allowedTenants = resourceOptions.NormalizedTenants.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", resourceOptions.NormalizedTenants);
|
||||
|
||||
logger.LogDebug(
|
||||
"Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}",
|
||||
allowedTenants,
|
||||
normalizedTenant ?? "(none)",
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
// tenant mismatch cannot be resolved by checking additional scopes for this principal
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, combinedScopes))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var required = string.Join(", ", combinedScopes);
|
||||
var principalScopes = userScopes is null || userScopes.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", userScopes);
|
||||
var tenantValue = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)";
|
||||
|
||||
logger.LogDebug(
|
||||
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Tenant={Tenant}; Remote={Remote}",
|
||||
required,
|
||||
principalScopes,
|
||||
tenantValue,
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
|
||||
{
|
||||
normalizedTenant = null;
|
||||
|
||||
if (options.NormalizedTenants.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (string.IsNullOrWhiteSpace(rawTenant))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedTenant = rawTenant.Trim().ToLowerInvariant();
|
||||
|
||||
foreach (var allowed in options.NormalizedTenants)
|
||||
{
|
||||
if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scopes.Add(claim.Value);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
scopes.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CombineRequiredScopes(
|
||||
IReadOnlyList<string> defaultScopes,
|
||||
IReadOnlyCollection<string> requirementScopes)
|
||||
{
|
||||
if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (defaultScopes is null || defaultScopes.Count == 0)
|
||||
{
|
||||
return requirementScopes is string[] requirementArray
|
||||
? requirementArray
|
||||
: requirementScopes.ToArray();
|
||||
}
|
||||
|
||||
var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal);
|
||||
|
||||
if (requirementScopes is not null)
|
||||
{
|
||||
foreach (var scope in requirementScopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
combined.Add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combined.Count == defaultScopes.Count && requirementScopes is null
|
||||
? defaultScopes
|
||||
: combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="StellaOpsScopeRequirement"/> evaluation.
|
||||
/// </summary>
|
||||
internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<StellaOpsScopeRequirement>
|
||||
{
|
||||
private const string ResourceEventType = "authority.resource.authorize";
|
||||
private static readonly TimeSpan ObservabilityIncidentFreshAuthWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly StellaOpsBypassEvaluator bypassEvaluator;
|
||||
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
|
||||
private readonly IEnumerable<IAuthEventSink> auditSinks;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
|
||||
|
||||
public StellaOpsScopeAuthorizationHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
StellaOpsBypassEvaluator bypassEvaluator,
|
||||
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
|
||||
IEnumerable<IAuthEventSink> auditSinks,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StellaOpsScopeAuthorizationHandler> logger)
|
||||
{
|
||||
this.httpContextAccessor = httpContextAccessor;
|
||||
this.bypassEvaluator = bypassEvaluator;
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.auditSinks = auditSinks ?? Array.Empty<IAuthEventSink>();
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
StellaOpsScopeRequirement requirement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(requirement);
|
||||
|
||||
var resourceOptions = optionsMonitor.CurrentValue;
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes);
|
||||
var principal = context.User;
|
||||
var principalAuthenticated = principal?.Identity?.IsAuthenticated == true;
|
||||
var principalScopes = principalAuthenticated
|
||||
? ExtractScopes(principal!)
|
||||
: new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var anyScopeMatched = false;
|
||||
var missingScopes = new List<string>();
|
||||
|
||||
if (principalAuthenticated)
|
||||
{
|
||||
foreach (var scope in combinedScopes)
|
||||
{
|
||||
if (principalScopes.Contains(scope))
|
||||
{
|
||||
anyScopeMatched = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
missingScopes.Add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (combinedScopes.Count > 0)
|
||||
{
|
||||
missingScopes.AddRange(combinedScopes);
|
||||
}
|
||||
|
||||
var allScopesSatisfied = combinedScopes.Count == 0
|
||||
? false
|
||||
: missingScopes.Count == 0;
|
||||
|
||||
var tenantAllowed = false;
|
||||
var tenantMismatch = false;
|
||||
string? normalizedTenant = null;
|
||||
|
||||
var incidentFreshAuthRequired = combinedScopes.Contains(StellaOpsScopes.ObservabilityIncident);
|
||||
var incidentFreshAuthSatisfied = true;
|
||||
string? incidentReasonClaim = null;
|
||||
DateTimeOffset? incidentAuthTime = null;
|
||||
string? incidentFailureReason = null;
|
||||
|
||||
if (principalAuthenticated)
|
||||
{
|
||||
incidentReasonClaim = principal!.FindFirstValue(StellaOpsClaimTypes.IncidentReason);
|
||||
}
|
||||
|
||||
if (principalAuthenticated && allScopesSatisfied)
|
||||
{
|
||||
tenantAllowed = TenantAllowed(principal!, resourceOptions, out normalizedTenant);
|
||||
tenantMismatch = !tenantAllowed;
|
||||
}
|
||||
|
||||
if (principalAuthenticated && tenantAllowed && allScopesSatisfied && incidentFreshAuthRequired)
|
||||
{
|
||||
incidentFreshAuthSatisfied = ValidateObservabilityIncidentFreshAuthentication(
|
||||
principal!,
|
||||
out incidentReasonClaim,
|
||||
out incidentAuthTime,
|
||||
out incidentFailureReason);
|
||||
}
|
||||
|
||||
var bypassed = false;
|
||||
|
||||
if ((!principalAuthenticated || !allScopesSatisfied || !tenantAllowed || !incidentFreshAuthSatisfied) &&
|
||||
httpContext is not null &&
|
||||
bypassEvaluator.ShouldBypass(httpContext, combinedScopes))
|
||||
{
|
||||
tenantAllowed = true;
|
||||
tenantMismatch = false;
|
||||
allScopesSatisfied = true;
|
||||
anyScopeMatched = true;
|
||||
missingScopes.Clear();
|
||||
incidentFreshAuthSatisfied = true;
|
||||
incidentFailureReason = null;
|
||||
incidentAuthTime = null;
|
||||
bypassed = true;
|
||||
}
|
||||
|
||||
if (tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
if (tenantMismatch)
|
||||
{
|
||||
var allowedTenants = resourceOptions.NormalizedTenants.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", resourceOptions.NormalizedTenants);
|
||||
|
||||
logger.LogDebug(
|
||||
"Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}",
|
||||
allowedTenants,
|
||||
normalizedTenant ?? "(none)",
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
var required = combinedScopes.Count == 0 ? "(none)" : string.Join(", ", combinedScopes);
|
||||
var principalScopeList = principalScopes.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", principalScopes);
|
||||
var tenantValue = normalizedTenant ?? principal?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)";
|
||||
var missing = missingScopes.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", missingScopes);
|
||||
|
||||
logger.LogDebug(
|
||||
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Missing={MissingScopes}; Tenant={Tenant}; Remote={Remote}",
|
||||
required,
|
||||
principalScopeList,
|
||||
missing,
|
||||
tenantValue,
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
|
||||
if (incidentFreshAuthRequired && !incidentFreshAuthSatisfied)
|
||||
{
|
||||
var authTimeText = incidentAuthTime?.ToString("o", CultureInfo.InvariantCulture) ?? "(unknown)";
|
||||
logger.LogDebug(
|
||||
"Incident scope fresh-auth requirement not satisfied. AuthTime={AuthTime}; Window={Window}; Remote={Remote}",
|
||||
authTimeText,
|
||||
ObservabilityIncidentFreshAuthWindow,
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
}
|
||||
|
||||
var reason = incidentFailureReason ?? DetermineFailureReason(
|
||||
principalAuthenticated,
|
||||
allScopesSatisfied,
|
||||
anyScopeMatched,
|
||||
tenantMismatch,
|
||||
combinedScopes.Count);
|
||||
if (bypassed)
|
||||
{
|
||||
reason = "Matched trusted bypass network.";
|
||||
}
|
||||
|
||||
await EmitAuditEventAsync(
|
||||
httpContext,
|
||||
principal,
|
||||
combinedScopes,
|
||||
principalScopes,
|
||||
resourceOptions,
|
||||
normalizedTenant,
|
||||
missingScopes,
|
||||
tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied,
|
||||
bypassed,
|
||||
reason,
|
||||
principalAuthenticated,
|
||||
allScopesSatisfied,
|
||||
anyScopeMatched,
|
||||
tenantMismatch,
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReasonClaim,
|
||||
incidentAuthTime).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? DetermineFailureReason(
|
||||
bool principalAuthenticated,
|
||||
bool allScopesSatisfied,
|
||||
bool anyScopeMatched,
|
||||
bool tenantMismatch,
|
||||
int requiredScopeCount)
|
||||
{
|
||||
if (!principalAuthenticated)
|
||||
{
|
||||
return "Principal not authenticated.";
|
||||
}
|
||||
|
||||
if (!allScopesSatisfied)
|
||||
{
|
||||
if (requiredScopeCount == 0)
|
||||
{
|
||||
return "No scopes configured for resource server.";
|
||||
}
|
||||
|
||||
return anyScopeMatched
|
||||
? "Required scopes not granted."
|
||||
: "Required scopes not granted.";
|
||||
}
|
||||
|
||||
if (tenantMismatch)
|
||||
{
|
||||
return "Tenant requirement not satisfied.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
|
||||
{
|
||||
normalizedTenant = null;
|
||||
|
||||
if (options.NormalizedTenants.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (string.IsNullOrWhiteSpace(rawTenant))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedTenant = rawTenant.Trim().ToLowerInvariant();
|
||||
|
||||
foreach (var allowed in options.NormalizedTenants)
|
||||
{
|
||||
if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task EmitAuditEventAsync(
|
||||
HttpContext? httpContext,
|
||||
ClaimsPrincipal? principal,
|
||||
IReadOnlyList<string> requiredScopes,
|
||||
IReadOnlyCollection<string> principalScopes,
|
||||
StellaOpsResourceServerOptions resourceOptions,
|
||||
string? normalizedTenant,
|
||||
IReadOnlyCollection<string> missingScopes,
|
||||
bool succeeded,
|
||||
bool bypassed,
|
||||
string? reason,
|
||||
bool principalAuthenticated,
|
||||
bool allScopesSatisfied,
|
||||
bool anyScopeMatched,
|
||||
bool tenantMismatch,
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
{
|
||||
if (!auditSinks.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = BuildAuditRecord(
|
||||
httpContext,
|
||||
principal,
|
||||
requiredScopes,
|
||||
principalScopes,
|
||||
resourceOptions,
|
||||
normalizedTenant,
|
||||
missingScopes,
|
||||
succeeded,
|
||||
bypassed,
|
||||
reason,
|
||||
principalAuthenticated,
|
||||
allScopesSatisfied,
|
||||
anyScopeMatched,
|
||||
tenantMismatch,
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReason,
|
||||
incidentAuthTime);
|
||||
|
||||
var cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None;
|
||||
|
||||
foreach (var sink in auditSinks)
|
||||
{
|
||||
await sink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to emit resource server authorization audit event.");
|
||||
}
|
||||
}
|
||||
|
||||
private AuthEventRecord BuildAuditRecord(
|
||||
HttpContext? httpContext,
|
||||
ClaimsPrincipal? principal,
|
||||
IReadOnlyList<string> requiredScopes,
|
||||
IReadOnlyCollection<string> principalScopes,
|
||||
StellaOpsResourceServerOptions resourceOptions,
|
||||
string? normalizedTenant,
|
||||
IReadOnlyCollection<string> missingScopes,
|
||||
bool succeeded,
|
||||
bool bypassed,
|
||||
string? reason,
|
||||
bool principalAuthenticated,
|
||||
bool allScopesSatisfied,
|
||||
bool anyScopeMatched,
|
||||
bool tenantMismatch,
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
{
|
||||
var correlationId = ResolveCorrelationId(httpContext);
|
||||
var subject = BuildSubject(principal);
|
||||
var client = BuildClient(principal);
|
||||
var network = BuildNetwork(httpContext);
|
||||
var tenantClaim = principal?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
var tenantValue = ClassifiedString.Public(normalizedTenant ?? tenantClaim?.Trim().ToLowerInvariant());
|
||||
var properties = BuildAuthProperties(
|
||||
resourceOptions,
|
||||
principalScopes,
|
||||
missingScopes,
|
||||
bypassed,
|
||||
principalAuthenticated,
|
||||
allScopesSatisfied,
|
||||
anyScopeMatched,
|
||||
tenantMismatch,
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReason,
|
||||
incidentAuthTime);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = ResourceEventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = succeeded ? AuthEventOutcome.Success : AuthEventOutcome.Failure,
|
||||
Reason = reason,
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Tenant = tenantValue,
|
||||
Scopes = requiredScopes,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildAuthProperties(
|
||||
StellaOpsResourceServerOptions resourceOptions,
|
||||
IReadOnlyCollection<string> principalScopes,
|
||||
IReadOnlyCollection<string> missingScopes,
|
||||
bool bypassed,
|
||||
bool principalAuthenticated,
|
||||
bool allScopesSatisfied,
|
||||
bool anyScopeMatched,
|
||||
bool tenantMismatch,
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (resourceOptions.Audiences.Count > 0)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.audience",
|
||||
Value = ClassifiedString.Public(string.Join(",", resourceOptions.Audiences))
|
||||
});
|
||||
}
|
||||
|
||||
if (resourceOptions.NormalizedTenants.Count > 0)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.tenants.allowed",
|
||||
Value = ClassifiedString.Public(string.Join(",", resourceOptions.NormalizedTenants))
|
||||
});
|
||||
}
|
||||
|
||||
if (principalScopes.Count > 0)
|
||||
{
|
||||
var joined = string.Join(" ", principalScopes.OrderBy(static scope => scope, StringComparer.Ordinal));
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "principal.scopes",
|
||||
Value = ClassifiedString.Public(joined)
|
||||
});
|
||||
}
|
||||
|
||||
if (missingScopes.Count > 0)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.scopes.missing",
|
||||
Value = ClassifiedString.Public(string.Join(" ", missingScopes))
|
||||
});
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "principal.authenticated",
|
||||
Value = ClassifiedString.Public(principalAuthenticated ? "true" : "false")
|
||||
});
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.scopes.all_satisfied",
|
||||
Value = ClassifiedString.Public(allScopesSatisfied ? "true" : "false")
|
||||
});
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.scopes.any_matched",
|
||||
Value = ClassifiedString.Public(anyScopeMatched ? "true" : "false")
|
||||
});
|
||||
|
||||
if (tenantMismatch)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.tenant.mismatch",
|
||||
Value = ClassifiedString.Public("true")
|
||||
});
|
||||
}
|
||||
|
||||
if (bypassed)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "resource.authorization.bypass",
|
||||
Value = ClassifiedString.Public("true")
|
||||
});
|
||||
}
|
||||
|
||||
if (incidentFreshAuthRequired)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "incident.fresh_auth_satisfied",
|
||||
Value = ClassifiedString.Public(incidentFreshAuthSatisfied ? "true" : "false")
|
||||
});
|
||||
|
||||
if (incidentAuthTime.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "incident.auth_time",
|
||||
Value = ClassifiedString.Public(incidentAuthTime.Value.ToString("o", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(incidentReason))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "incident.reason",
|
||||
Value = ClassifiedString.Sensitive(incidentReason!)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private bool ValidateObservabilityIncidentFreshAuthentication(
|
||||
ClaimsPrincipal principal,
|
||||
out string? incidentReason,
|
||||
out DateTimeOffset? authenticationTime,
|
||||
out string? failureReason)
|
||||
{
|
||||
incidentReason = principal.FindFirstValue(StellaOpsClaimTypes.IncidentReason)?.Trim();
|
||||
authenticationTime = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(incidentReason))
|
||||
{
|
||||
failureReason = "obs:incident tokens require incident_reason claim.";
|
||||
LogIncidentValidationFailure(principal, failureReason);
|
||||
return false;
|
||||
}
|
||||
|
||||
var authTimeClaim = principal.FindFirstValue(OpenIddictConstants.Claims.AuthenticationTime);
|
||||
if (string.IsNullOrWhiteSpace(authTimeClaim) ||
|
||||
!long.TryParse(authTimeClaim, NumberStyles.Integer, CultureInfo.InvariantCulture, out var authTimeSeconds))
|
||||
{
|
||||
failureReason = "obs:incident tokens require authentication_time claim.";
|
||||
LogIncidentValidationFailure(principal, failureReason);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
authenticationTime = DateTimeOffset.FromUnixTimeSeconds(authTimeSeconds);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
failureReason = "obs:incident tokens contain an invalid authentication_time value.";
|
||||
LogIncidentValidationFailure(principal, failureReason);
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (now - authenticationTime > ObservabilityIncidentFreshAuthWindow)
|
||||
{
|
||||
failureReason = "obs:incident tokens require fresh authentication.";
|
||||
LogIncidentValidationFailure(principal, failureReason, authenticationTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
failureReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void LogIncidentValidationFailure(
|
||||
ClaimsPrincipal principal,
|
||||
string message,
|
||||
DateTimeOffset? authenticationTime = null)
|
||||
{
|
||||
var clientId = principal.FindFirstValue(StellaOpsClaimTypes.ClientId) ?? "<unknown>";
|
||||
var subject = principal.FindFirstValue(StellaOpsClaimTypes.Subject) ?? "<unknown>";
|
||||
|
||||
if (authenticationTime.HasValue)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"{Message} ClientId={ClientId}; Subject={Subject}; AuthTime={AuthTime:o}; Window={Window}",
|
||||
message,
|
||||
clientId,
|
||||
subject,
|
||||
authenticationTime.Value,
|
||||
ObservabilityIncidentFreshAuthWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"{Message} ClientId={ClientId}; Subject={Subject}",
|
||||
message,
|
||||
clientId,
|
||||
subject);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCorrelationId(HttpContext? httpContext)
|
||||
{
|
||||
if (Activity.Current is { TraceId: var traceId } && traceId != default)
|
||||
{
|
||||
return traceId.ToString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(httpContext?.TraceIdentifier))
|
||||
{
|
||||
return httpContext.TraceIdentifier!;
|
||||
}
|
||||
|
||||
return Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static AuthEventSubject? BuildSubject(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subjectId = ClassifiedString.Personal(principal.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
var username = ClassifiedString.Personal(principal.Identity?.Name);
|
||||
|
||||
if (!subjectId.HasValue && !username.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventSubject
|
||||
{
|
||||
SubjectId = subjectId,
|
||||
Username = username
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventClient? BuildClient(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clientId = principal.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(clientId),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext? httpContext)
|
||||
{
|
||||
if (httpContext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remote = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var forwarded = GetHeaderValue(httpContext, "X-Forwarded-For");
|
||||
if (string.IsNullOrWhiteSpace(forwarded))
|
||||
{
|
||||
forwarded = GetHeaderValue(httpContext, "Forwarded");
|
||||
}
|
||||
|
||||
var userAgent = GetHeaderValue(httpContext, "User-Agent");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remote),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded),
|
||||
UserAgent = ClassifiedString.Personal(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetHeaderValue(HttpContext httpContext, string name)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue(name, out var values) && values.Count > 0)
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scopes.Add(claim.Value);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
scopes.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CombineRequiredScopes(
|
||||
IReadOnlyList<string> defaultScopes,
|
||||
IReadOnlyCollection<string> requirementScopes)
|
||||
{
|
||||
if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (defaultScopes is null || defaultScopes.Count == 0)
|
||||
{
|
||||
return requirementScopes is string[] requirementArray
|
||||
? requirementArray
|
||||
: requirementScopes.ToArray();
|
||||
}
|
||||
|
||||
var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal);
|
||||
|
||||
if (requirementScopes is not null)
|
||||
{
|
||||
foreach (var scope in requirementScopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
combined.Add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combined.Count == defaultScopes.Count && requirementScopes is null
|
||||
? defaultScopes
|
||||
: combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user