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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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();
}
}