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

@@ -7,16 +7,22 @@ public class StellaOpsScopesTests
{
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.AdvisoryAiView)]
[InlineData(StellaOpsScopes.AdvisoryAiOperate)]
[InlineData(StellaOpsScopes.AdvisoryAiAdmin)]
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)]
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)]
[InlineData(StellaOpsScopes.AirgapSeal)]
[InlineData(StellaOpsScopes.AirgapImport)]
[InlineData(StellaOpsScopes.AirgapStatusRead)]
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
@@ -28,27 +34,52 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.GraphRead)]
[InlineData(StellaOpsScopes.VulnRead)]
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)]
[InlineData(StellaOpsScopes.OrchOperate)]
[InlineData(StellaOpsScopes.ExportViewer)]
[InlineData(StellaOpsScopes.ExportOperator)]
[InlineData(StellaOpsScopes.ExportAdmin)]
public void All_IncludesNewScopes(string scope)
{
Assert.Contains(scope, StellaOpsScopes.All);
}
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)]
[InlineData(StellaOpsScopes.OrchOperate)]
[InlineData(StellaOpsScopes.OrchBackfill)]
[InlineData(StellaOpsScopes.OrchQuota)]
[InlineData(StellaOpsScopes.ExportViewer)]
[InlineData(StellaOpsScopes.ExportOperator)]
[InlineData(StellaOpsScopes.ExportAdmin)]
[InlineData(StellaOpsScopes.NotifyViewer)]
[InlineData(StellaOpsScopes.NotifyOperator)]
[InlineData(StellaOpsScopes.NotifyAdmin)]
[InlineData(StellaOpsScopes.NotifyEscalate)]
[InlineData(StellaOpsScopes.PacksRead)]
[InlineData(StellaOpsScopes.PacksWrite)]
[InlineData(StellaOpsScopes.PacksRun)]
[InlineData(StellaOpsScopes.PacksApprove)]
[InlineData(StellaOpsScopes.ObservabilityRead)]
[InlineData(StellaOpsScopes.TimelineRead)]
[InlineData(StellaOpsScopes.TimelineWrite)]
[InlineData(StellaOpsScopes.EvidenceCreate)]
[InlineData(StellaOpsScopes.EvidenceRead)]
[InlineData(StellaOpsScopes.EvidenceHold)]
[InlineData(StellaOpsScopes.AttestRead)]
[InlineData(StellaOpsScopes.ObservabilityIncident)]
[InlineData(StellaOpsScopes.AuthorityTenantsRead)]
public void All_IncludesNewScopes(string scope)
{
Assert.Contains(scope, StellaOpsScopes.All);
}
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
public void Normalize_NormalizesToLowerCase(string input, string expected)
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
}
}
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
[InlineData("AIRGAP:SEAL", StellaOpsScopes.AirgapSeal)]
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
[InlineData("Advisory-AI:Operate", StellaOpsScopes.AdvisoryAiOperate)]
[InlineData("Notify.Admin", StellaOpsScopes.NotifyAdmin)]
[InlineData("Packs.Run", StellaOpsScopes.PacksRun)]
[InlineData("Packs.Approve", StellaOpsScopes.PacksApprove)]
[InlineData("Notify.Escalate", StellaOpsScopes.NotifyEscalate)]
public void Normalize_NormalizesToLowerCase(string input, string expected)
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
}
}

View File

@@ -65,6 +65,31 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string OperatorTicket = "stellaops:operator_ticket";
/// <summary>
/// Quota change reason supplied when issuing Orchestrator quota tokens.
/// </summary>
public const string QuotaReason = "stellaops:quota_reason";
/// <summary>
/// Quota change ticket/incident reference supplied when issuing Orchestrator quota tokens.
/// </summary>
public const string QuotaTicket = "stellaops:quota_ticket";
/// <summary>
/// Backfill activation reason supplied when issuing orchestrator backfill tokens.
/// </summary>
public const string BackfillReason = "stellaops:backfill_reason";
/// <summary>
/// Backfill ticket/incident reference supplied when issuing orchestrator backfill tokens.
/// </summary>
public const string BackfillTicket = "stellaops:backfill_ticket";
/// <summary>
/// Incident activation reason recorded when issuing observability incident tokens.
/// </summary>
public const string IncidentReason = "stellaops:incident_reason";
/// <summary>
/// Session identifier claim (<c>sid</c>).
/// </summary>

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Shared HTTP header names used across StellaOps clients and services.
/// </summary>
public static class StellaOpsHttpHeaderNames
{
/// <summary>
/// Header used to convey the tenant override when issuing requests to StellaOps APIs.
/// </summary>
public const string Tenant = "X-StellaOps-Tenant";
}

View File

@@ -49,14 +49,29 @@ public static class StellaOpsScopes
public const string ExceptionsApprove = "exceptions:approve";
/// <summary>
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
public const string AdvisoryRead = "advisory:read";
/// <summary>
/// Scope granting write access for raw advisory ingestion.
/// </summary>
public const string AdvisoryIngest = "advisory:ingest";
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
public const string AdvisoryRead = "advisory:read";
/// <summary>
/// Scope granting write access for raw advisory ingestion.
/// </summary>
public const string AdvisoryIngest = "advisory:ingest";
/// <summary>
/// Scope granting read-only access to Advisory AI artefacts (summaries, remediation exports).
/// </summary>
public const string AdvisoryAiView = "advisory-ai:view";
/// <summary>
/// Scope permitting Advisory AI inference requests and workflow execution.
/// </summary>
public const string AdvisoryAiOperate = "advisory-ai:operate";
/// <summary>
/// Scope granting administrative control over Advisory AI configuration and profiles.
/// </summary>
public const string AdvisoryAiAdmin = "advisory-ai:admin";
/// <summary>
/// Scope granting read-only access to raw VEX ingestion data.
@@ -85,13 +100,28 @@ public static class StellaOpsScopes
/// <summary>
/// Scope granting administrative access to reachability signal ingestion.
/// </summary>
public const string SignalsAdmin = "signals:admin";
/// <summary>
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// </summary>
public const string SignalsAdmin = "signals:admin";
/// <summary>
/// Scope granting permission to seal or unseal an installation in air-gapped mode.
/// </summary>
public const string AirgapSeal = "airgap:seal";
/// <summary>
/// Scope granting permission to import offline bundles while in air-gapped mode.
/// </summary>
public const string AirgapImport = "airgap:import";
/// <summary>
/// Scope granting read-only access to air-gap status and sealing state endpoints.
/// </summary>
public const string AirgapStatusRead = "airgap:status:read";
/// <summary>
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// Scope granting permission to author Policy Studio workspaces.
@@ -163,11 +193,51 @@ public static class StellaOpsScopes
/// </summary>
public const string GraphRead = "graph:read";
/// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary>
public const string VulnRead = "vuln:read";
/// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary>
public const string VulnRead = "vuln:read";
/// <summary>
/// Scope granting read-only access to observability dashboards and overlays.
/// </summary>
public const string ObservabilityRead = "obs:read";
/// <summary>
/// Scope granting read-only access to incident timelines and chronology data.
/// </summary>
public const string TimelineRead = "timeline:read";
/// <summary>
/// Scope granting permission to append events to incident timelines.
/// </summary>
public const string TimelineWrite = "timeline:write";
/// <summary>
/// Scope granting permission to create evidence packets in the evidence locker.
/// </summary>
public const string EvidenceCreate = "evidence:create";
/// <summary>
/// Scope granting read-only access to stored evidence packets.
/// </summary>
public const string EvidenceRead = "evidence:read";
/// <summary>
/// Scope granting permission to place or release legal holds on evidence packets.
/// </summary>
public const string EvidenceHold = "evidence:hold";
/// <summary>
/// Scope granting read-only access to attestation records and observer feeds.
/// </summary>
public const string AttestRead = "attest:read";
/// <summary>
/// Scope granting permission to activate or resolve observability incident mode controls.
/// </summary>
public const string ObservabilityIncident = "obs:incident";
/// <summary>
/// Scope granting read-only access to export center runs and bundles.
/// </summary>
@@ -176,13 +246,68 @@ public static class StellaOpsScopes
/// <summary>
/// Scope granting permission to operate export center scheduling and run execution.
/// </summary>
public const string ExportOperator = "export.operator";
/// <summary>
/// Scope granting administrative control over export center retention, encryption keys, and scheduling policies.
/// </summary>
public const string ExportAdmin = "export.admin";
public const string ExportOperator = "export.operator";
/// <summary>
/// Scope granting administrative control over export center retention, encryption keys, and scheduling policies.
/// </summary>
public const string ExportAdmin = "export.admin";
/// <summary>
/// Scope granting read-only access to notifier channels, rules, and delivery history.
/// </summary>
public const string NotifyViewer = "notify.viewer";
/// <summary>
/// Scope permitting notifier rule management, delivery actions, and channel operations.
/// </summary>
public const string NotifyOperator = "notify.operator";
/// <summary>
/// Scope granting administrative control over notifier secrets, escalations, and platform-wide settings.
/// </summary>
public const string NotifyAdmin = "notify.admin";
/// <summary>
/// Scope granting read-only access to issuer directory catalogues.
/// </summary>
public const string IssuerDirectoryRead = "issuer-directory:read";
/// <summary>
/// Scope permitting creation and modification of issuer directory entries.
/// </summary>
public const string IssuerDirectoryWrite = "issuer-directory:write";
/// <summary>
/// Scope granting administrative control over issuer directory resources (delete, audit bypass).
/// </summary>
public const string IssuerDirectoryAdmin = "issuer-directory:admin";
/// <summary>
/// Scope required to issue or honour escalation actions for notifications.
/// </summary>
public const string NotifyEscalate = "notify.escalate";
/// <summary>
/// Scope granting read-only access to Task Packs catalogues and manifests.
/// </summary>
public const string PacksRead = "packs.read";
/// <summary>
/// Scope permitting publication or updates to Task Packs in the registry.
/// </summary>
public const string PacksWrite = "packs.write";
/// <summary>
/// Scope granting permission to execute Task Packs via CLI or Task Runner.
/// </summary>
public const string PacksRun = "packs.run";
/// <summary>
/// Scope granting permission to fulfil Task Pack approval gates.
/// </summary>
public const string PacksApprove = "packs.approve";
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
@@ -204,10 +329,20 @@ public static class StellaOpsScopes
public const string OrchRead = "orch:read";
/// <summary>
/// Scope granting permission to execute Orchestrator control actions.
/// </summary>
public const string OrchOperate = "orch:operate";
/// Scope granting permission to execute Orchestrator control actions.
/// </summary>
public const string OrchOperate = "orch:operate";
/// <summary>
/// Scope granting permission to manage Orchestrator quotas and elevated backfill tooling.
/// </summary>
public const string OrchQuota = "orch:quota";
/// <summary>
/// Scope granting permission to initiate orchestrator-controlled backfill runs.
/// </summary>
public const string OrchBackfill = "orch:backfill";
/// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary>
@@ -223,17 +358,23 @@ public static class StellaOpsScopes
Bypass,
UiRead,
ExceptionsApprove,
AdvisoryRead,
AdvisoryIngest,
VexRead,
VexIngest,
AocVerify,
SignalsRead,
SignalsWrite,
SignalsAdmin,
PolicyWrite,
PolicyAuthor,
PolicyEdit,
AdvisoryRead,
AdvisoryIngest,
AdvisoryAiView,
AdvisoryAiOperate,
AdvisoryAiAdmin,
VexRead,
VexIngest,
AocVerify,
SignalsRead,
SignalsWrite,
SignalsAdmin,
AirgapSeal,
AirgapImport,
AirgapStatusRead,
PolicyWrite,
PolicyAuthor,
PolicyEdit,
PolicyRead,
PolicyReview,
PolicySubmit,
@@ -245,18 +386,39 @@ public static class StellaOpsScopes
PolicySimulate,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnRead,
ExportViewer,
ExportOperator,
ExportAdmin,
GraphWrite,
GraphExport,
GraphSimulate,
OrchRead,
OrchOperate,
AuthorityTenantsRead
};
GraphRead,
VulnRead,
ObservabilityRead,
TimelineRead,
TimelineWrite,
EvidenceCreate,
EvidenceRead,
EvidenceHold,
AttestRead,
ObservabilityIncident,
ExportViewer,
ExportOperator,
ExportAdmin,
NotifyViewer,
NotifyOperator,
NotifyAdmin,
IssuerDirectoryRead,
IssuerDirectoryWrite,
IssuerDirectoryAdmin,
NotifyEscalate,
PacksRead,
PacksWrite,
PacksRun,
PacksApprove,
GraphWrite,
GraphExport,
GraphSimulate,
OrchRead,
OrchOperate,
OrchBackfill,
OrchQuota,
AuthorityTenantsRead
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).

View File

@@ -8,6 +8,9 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using Xunit;
@@ -92,4 +95,206 @@ public class ServiceCollectionExtensionsTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
[Fact]
public async Task AddStellaOpsApiAuthentication_AttachesPatAndTenantHeader()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
});
var tokenClient = new ThrowingTokenClient();
services.AddSingleton<IStellaOpsTokenClient>(tokenClient);
var handler = new RecordingHttpMessageHandler();
services.AddHttpClient("notify")
.ConfigurePrimaryHttpMessageHandler(() => handler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.PersonalAccessToken;
options.PersonalAccessToken = "pat-token";
options.Tenant = "tenant-123";
options.TenantHeader = "X-Custom-Tenant";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("notify");
var response = await client.GetAsync("https://notify.example/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(handler.AuthorizationHistory);
var authorization = handler.AuthorizationHistory[0];
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("pat-token", authorization.Parameter);
Assert.Single(handler.TenantHeaders);
Assert.Equal("tenant-123", handler.TenantHeaders[0]);
Assert.Equal(0, tokenClient.RequestCount);
}
[Fact]
public async Task AddStellaOpsApiAuthentication_UsesClientCredentialsWithCaching()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
options.ExpirationSkew = TimeSpan.FromSeconds(10);
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T00:00:00Z"));
services.AddSingleton<TimeProvider>(fakeTime);
var recordingTokenClient = new RecordingTokenClient(fakeTime);
services.AddSingleton<IStellaOpsTokenClient>(recordingTokenClient);
var handler = new RecordingHttpMessageHandler();
services.AddHttpClient("notify")
.ConfigurePrimaryHttpMessageHandler(() => handler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.ClientCredentials;
options.Scope = "notify.read";
options.Tenant = "tenant-oauth";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("notify");
await client.GetAsync("https://notify.example/api");
await client.GetAsync("https://notify.example/api");
Assert.Equal(2, handler.AuthorizationHistory.Count);
Assert.Equal(1, recordingTokenClient.ClientCredentialsCallCount);
Assert.All(handler.AuthorizationHistory, header =>
{
Assert.NotNull(header);
Assert.Equal("Bearer", header!.Scheme);
Assert.Equal("token-1", header.Parameter);
});
Assert.All(handler.TenantHeaders, value => Assert.Equal("tenant-oauth", value));
// Advance beyond expiry buffer to force refresh.
fakeTime.Advance(TimeSpan.FromMinutes(2));
await client.GetAsync("https://notify.example/api");
Assert.Equal(3, handler.AuthorizationHistory.Count);
Assert.Equal("token-2", handler.AuthorizationHistory[^1]!.Parameter);
Assert.Equal(2, recordingTokenClient.ClientCredentialsCallCount);
}
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
public List<AuthenticationHeaderValue?> AuthorizationHistory { get; } = new();
public List<string?> TenantHeaders { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AuthorizationHistory.Add(request.Headers.Authorization);
if (request.Headers.TryGetValues("X-Custom-Tenant", out var customTenant))
{
TenantHeaders.Add(customTenant.Single());
}
else if (request.Headers.TryGetValues("X-StellaOps-Tenant", out var defaultTenant))
{
TenantHeaders.Add(defaultTenant.Single());
}
else
{
TenantHeaders.Add(null);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private sealed class ThrowingTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new JsonWebKeySet());
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
throw new InvalidOperationException("Client credentials flow should not be invoked for PAT mode.");
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
throw new InvalidOperationException("Password flow should not be invoked for PAT mode.");
}
}
private sealed class RecordingTokenClient : IStellaOpsTokenClient
{
private readonly FakeTimeProvider timeProvider;
private int tokenCounter;
public RecordingTokenClient(FakeTimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public int ClientCredentialsCallCount { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
ClientCredentialsCallCount++;
var tokenId = Interlocked.Increment(ref tokenCounter);
var result = new StellaOpsTokenResult(
$"token-{tokenId}",
"Bearer",
timeProvider.GetUtcNow().AddMinutes(1),
scope is null ? Array.Empty<string>() : new[] { scope },
null,
null,
"{}");
return Task.FromResult(result);
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new JsonWebKeySet());
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
}

View File

@@ -5,5 +5,7 @@ Typed OpenID Connect client used by StellaOps services, agents, and tooling to t
- Discovery + JWKS caching with deterministic refresh windows.
- Password and client-credential flows with token cache abstractions.
- Configurable HTTP retry/backoff policies (Polly) and offline fallback support for air-gapped deployments.
- `HttpClient` authentication helpers that attach OAuth2 (password/client-credentials) or personal access tokens,
including automatic `X-StellaOps-Tenant` header injection for multi-tenant APIs.
See `docs/dev/32_AUTH_CLIENT_GUIDE.md` in the repository for integration guidance, option descriptions, and rollout checklists.

View File

@@ -68,6 +68,29 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Adds authentication and tenancy header handling for an <see cref="HttpClient"/> registered via <see cref="IHttpClientBuilder"/>.
/// </summary>
public static IHttpClientBuilder AddStellaOpsApiAuthentication(this IHttpClientBuilder builder, Action<StellaOpsApiAuthenticationOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
builder.Services.AddOptions<StellaOpsApiAuthenticationOptions>(builder.Name)
.Configure(configure)
.PostConfigure(static options => options.Validate());
builder.AddHttpMessageHandler(provider => new StellaOpsBearerTokenHandler(
builder.Name,
provider.GetRequiredService<IOptionsMonitor<StellaOpsApiAuthenticationOptions>>(),
provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>(),
provider.GetRequiredService<IStellaOpsTokenClient>(),
provider.GetService<TimeProvider>(),
provider.GetService<ILogger<StellaOpsBearerTokenHandler>>()));
return builder;
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Auth.Client;
/// <summary>
/// Authentication strategies supported by the StellaOps API client helpers.
/// </summary>
public enum StellaOpsApiAuthMode
{
/// <summary>
/// Use the OAuth 2.0 client credentials grant to request access tokens.
/// </summary>
ClientCredentials,
/// <summary>
/// Use the resource owner password credentials grant to request access tokens.
/// </summary>
Password,
/// <summary>
/// Use a pre-issued personal access token (PAT) as the bearer credential.
/// </summary>
PersonalAccessToken
}

View File

@@ -0,0 +1,97 @@
using System;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.Client;
/// <summary>
/// Options controlling how <see cref="HttpClient"/> instances obtain authentication and tenancy headers.
/// </summary>
public sealed class StellaOpsApiAuthenticationOptions
{
private string tenantHeader = StellaOpsHttpHeaderNames.Tenant;
/// <summary>
/// Gets or sets the authentication mode used to authorise outbound requests.
/// </summary>
public StellaOpsApiAuthMode Mode { get; set; } = StellaOpsApiAuthMode.ClientCredentials;
/// <summary>
/// Optional scope override supplied when requesting OAuth access tokens.
/// </summary>
public string? Scope { get; set; }
/// <summary>
/// Username used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.Password"/>.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Password used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.Password"/>.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Pre-issued personal access token used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.PersonalAccessToken"/>.
/// </summary>
public string? PersonalAccessToken { get; set; }
/// <summary>
/// Optional tenant identifier injected via <see cref="TenantHeader"/>. If <c>null</c>, the header is omitted.
/// </summary>
public string? Tenant { get; set; }
/// <summary>
/// Header name used to convey the tenant override (defaults to <c>X-StellaOps-Tenant</c>).
/// </summary>
public string TenantHeader
{
get => tenantHeader;
set => tenantHeader = string.IsNullOrWhiteSpace(value) ? StellaOpsHttpHeaderNames.Tenant : value.Trim();
}
/// <summary>
/// Buffer window applied before token expiration that triggers proactive refresh (defaults to 30 seconds).
/// </summary>
public TimeSpan RefreshBuffer { get; set; } = TimeSpan.FromSeconds(30);
internal void Validate()
{
if (RefreshBuffer < TimeSpan.Zero || RefreshBuffer > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("RefreshBuffer must be between 0 seconds and 5 minutes.");
}
Tenant = string.IsNullOrWhiteSpace(Tenant) ? null : Tenant.Trim();
Scope = string.IsNullOrWhiteSpace(Scope) ? null : Scope.Trim();
switch (Mode)
{
case StellaOpsApiAuthMode.ClientCredentials:
break;
case StellaOpsApiAuthMode.Password:
if (string.IsNullOrWhiteSpace(Username))
{
throw new InvalidOperationException("Username is required for password authentication.");
}
if (string.IsNullOrEmpty(Password))
{
throw new InvalidOperationException("Password is required for password authentication.");
}
Username = Username.Trim();
break;
case StellaOpsApiAuthMode.PersonalAccessToken:
if (string.IsNullOrWhiteSpace(PersonalAccessToken))
{
throw new InvalidOperationException("PersonalAccessToken is required when using personal access token mode.");
}
PersonalAccessToken = PersonalAccessToken.Trim();
break;
default:
throw new InvalidOperationException($"Unsupported authentication mode '{Mode}'.");
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Auth.Client;
/// <summary>
/// Delegating handler that attaches bearer credentials and tenant headers to outbound requests.
/// </summary>
internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
{
private readonly string clientName;
private readonly IOptionsMonitor<StellaOpsApiAuthenticationOptions> apiAuthOptions;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> authClientOptions;
private readonly IStellaOpsTokenClient tokenClient;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsBearerTokenHandler>? logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private StellaOpsTokenResult? cachedToken;
public StellaOpsBearerTokenHandler(
string clientName,
IOptionsMonitor<StellaOpsApiAuthenticationOptions> apiAuthOptions,
IOptionsMonitor<StellaOpsAuthClientOptions> authClientOptions,
IStellaOpsTokenClient tokenClient,
TimeProvider? timeProvider,
ILogger<StellaOpsBearerTokenHandler>? logger)
{
this.clientName = clientName ?? throw new ArgumentNullException(nameof(clientName));
this.apiAuthOptions = apiAuthOptions ?? throw new ArgumentNullException(nameof(apiAuthOptions));
this.authClientOptions = authClientOptions ?? throw new ArgumentNullException(nameof(authClientOptions));
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var options = apiAuthOptions.Get(clientName);
if (!string.IsNullOrWhiteSpace(options.Tenant))
{
request.Headers.Remove(options.TenantHeader);
request.Headers.TryAddWithoutValidation(options.TenantHeader, options.Tenant);
}
var token = await ResolveTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private async Task<string?> ResolveTokenAsync(StellaOpsApiAuthenticationOptions options, CancellationToken cancellationToken)
{
if (options.Mode == StellaOpsApiAuthMode.PersonalAccessToken)
{
return options.PersonalAccessToken;
}
var buffer = GetRefreshBuffer(options);
var now = timeProvider.GetUtcNow();
var token = cachedToken;
if (token is not null && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
token = cachedToken;
now = timeProvider.GetUtcNow();
if (token is not null && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
StellaOpsTokenResult result = options.Mode switch
{
StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync(
options.Scope,
null,
cancellationToken).ConfigureAwait(false),
StellaOpsApiAuthMode.Password => await tokenClient.RequestPasswordTokenAsync(
options.Username!,
options.Password!,
options.Scope,
null,
cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Unsupported authentication mode '{options.Mode}'.")
};
cachedToken = result;
logger?.LogDebug("Issued access token for client {ClientName}; expires at {ExpiresAt}.", clientName, result.ExpiresAt);
return result.AccessToken;
}
finally
{
refreshLock.Release();
}
}
private TimeSpan GetRefreshBuffer(StellaOpsApiAuthenticationOptions options)
{
var authOptions = authClientOptions.CurrentValue;
var buffer = options.RefreshBuffer;
if (buffer <= TimeSpan.Zero)
{
return authOptions.ExpirationSkew;
}
return buffer > authOptions.ExpirationSkew ? buffer : authOptions.ExpirationSkew;
}
}

View File

@@ -15,6 +15,11 @@ public sealed record StellaOpsTokenResult(
string? IdToken = null,
string? RawResponse = null)
{
/// <summary>
/// Temporary shim for callers expecting the legacy <c>ExpiresAt</c> member.
/// </summary>
public DateTimeOffset ExpiresAt => ExpiresAtUtc;
/// <summary>
/// Converts the result to a cache entry.
/// </summary>

View File

@@ -0,0 +1,39 @@
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerPoliciesTests
{
[Fact]
public void AddObservabilityResourcePolicies_RegistersExpectedPolicies()
{
var options = new AuthorizationOptions();
options.AddObservabilityResourcePolicies();
AssertPolicy(options, StellaOpsResourceServerPolicies.ObservabilityRead, StellaOpsScopes.ObservabilityRead);
AssertPolicy(options, StellaOpsResourceServerPolicies.ObservabilityIncident, StellaOpsScopes.ObservabilityIncident);
AssertPolicy(options, StellaOpsResourceServerPolicies.TimelineRead, StellaOpsScopes.TimelineRead);
AssertPolicy(options, StellaOpsResourceServerPolicies.TimelineWrite, StellaOpsScopes.TimelineWrite);
AssertPolicy(options, StellaOpsResourceServerPolicies.EvidenceCreate, StellaOpsScopes.EvidenceCreate);
AssertPolicy(options, StellaOpsResourceServerPolicies.EvidenceRead, StellaOpsScopes.EvidenceRead);
AssertPolicy(options, StellaOpsResourceServerPolicies.EvidenceHold, StellaOpsScopes.EvidenceHold);
AssertPolicy(options, StellaOpsResourceServerPolicies.AttestRead, StellaOpsScopes.AttestRead);
AssertPolicy(options, StellaOpsResourceServerPolicies.ExportViewer, StellaOpsScopes.ExportViewer);
AssertPolicy(options, StellaOpsResourceServerPolicies.ExportOperator, StellaOpsScopes.ExportOperator);
AssertPolicy(options, StellaOpsResourceServerPolicies.ExportAdmin, StellaOpsScopes.ExportAdmin);
}
private static void AssertPolicy(AuthorizationOptions options, string policyName, string expectedScope)
{
var policy = options.GetPolicy(policyName);
Assert.NotNull(policy);
var requirement = Assert.Single(policy!.Requirements.OfType<StellaOpsScopeRequirement>());
Assert.Equal(new[] { expectedScope }, requirement.RequiredScopes);
}
}

View File

@@ -1,14 +1,20 @@
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Cryptography.Audit;
using OpenIddict.Abstractions;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
@@ -24,158 +30,322 @@ public class StellaOpsScopeAuthorizationHandlerTests
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
[Fact]
public async Task HandleRequirement_Fails_WhenTenantMismatch()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-beta")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal(StellaOpsScopes.ConcelierJobsTrigger, Assert.Single(record.Scopes));
Assert.Equal("tenant-alpha", record.Tenant.Value);
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
Assert.Null(GetPropertyValue(record, "resource.authorization.bypass"));
Assert.False(string.IsNullOrWhiteSpace(record.CorrelationId));
}
[Fact]
public async Task HandleRequirement_Fails_WhenTenantMismatch()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-beta")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("tenant-beta", record.Tenant.Value);
Assert.Equal("Tenant requirement not satisfied.", record.Reason);
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
Assert.Equal("true", GetPropertyValue(record, "resource.tenant.mismatch"));
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal("Matched trusted bypass network.", record.Reason);
Assert.Equal("true", GetPropertyValue(record, "resource.authorization.bypass"));
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Principal not authenticated.", record.Reason);
Assert.Equal("false", GetPropertyValue(record, "principal.authenticated"));
}
[Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Required scopes not granted.", record.Reason);
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
Assert.Equal(StellaOpsScopes.PolicyRun, GetPropertyValue(record, "resource.scopes.missing"));
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger, StellaOpsScopes.PolicyRun })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
optionsMonitor,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger, StellaOpsScopes.PolicyRun })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Null(record.Reason);
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
}
[Fact]
public async Task HandleRequirement_Fails_WhenIncidentAuthTimeMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture));
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("10.0.0.50"), fakeTime);
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ObservabilityIncident });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-incident")
.WithClientId("incident-client")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ObservabilityIncident })
.AddClaim(StellaOpsClaimTypes.IncidentReason, "Sev1 drill")
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("obs:incident tokens require authentication_time claim.", record.Reason);
Assert.Equal("false", GetPropertyValue(record, "incident.fresh_auth_satisfied"));
Assert.Equal("Sev1 drill", GetPropertyValue(record, "incident.reason"));
}
[Fact]
public async Task HandleRequirement_Fails_WhenIncidentAuthTimeStale()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture));
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("10.0.0.51"), fakeTime);
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ObservabilityIncident });
var staleAuthTime = fakeTime.GetUtcNow().AddMinutes(-10);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-incident")
.WithClientId("incident-client")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ObservabilityIncident })
.AddClaim(StellaOpsClaimTypes.IncidentReason, "Sev1 drill")
.AddClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("obs:incident tokens require fresh authentication.", record.Reason);
Assert.Equal("false", GetPropertyValue(record, "incident.fresh_auth_satisfied"));
Assert.Equal(staleAuthTime.ToString("o", CultureInfo.InvariantCulture), GetPropertyValue(record, "incident.auth_time"));
Assert.Equal("Sev1 drill", GetPropertyValue(record, "incident.reason"));
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenIncidentFreshAuthValid()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture));
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("10.0.0.52"), fakeTime);
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ObservabilityIncident });
var freshAuthTime = fakeTime.GetUtcNow().AddMinutes(-2);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-incident")
.WithClientId("incident-client")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ObservabilityIncident })
.AddClaim(StellaOpsClaimTypes.IncidentReason, "Sev1 drill")
.AddClaim(OpenIddictConstants.Claims.AuthenticationTime, freshAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal("true", GetPropertyValue(record, "incident.fresh_auth_satisfied"));
Assert.Equal(freshAuthTime.ToString("o", CultureInfo.InvariantCulture), GetPropertyValue(record, "incident.auth_time"));
Assert.Equal("Sev1 drill", GetPropertyValue(record, "incident.reason"));
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor, RecordingAuthEventSink Sink) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress, TimeProvider? timeProvider = null)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
httpContext.TraceIdentifier = $"trace-{remoteAddress}";
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var sink = new RecordingAuthEventSink();
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
optionsMonitor,
new[] { sink },
timeProvider ?? TimeProvider.System,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor, sink);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private static string? GetPropertyValue(AuthEventRecord record, string propertyName)
{
foreach (var property in record.Properties)
{
if (string.Equals(property.Name, propertyName, StringComparison.Ordinal))
{
return property.Value.Value;
}
}
return null;
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly List<AuthEventRecord> records = new();
public IReadOnlyList<AuthEventRecord> Records => records;
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
records.Add(record);
return ValueTask.CompletedTask;
}
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();

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

View File

@@ -1,11 +1,11 @@
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known metadata keys persisted with Authority client registrations.
/// </summary>
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known metadata keys persisted with Authority client registrations.
/// </summary>
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
public const string RedirectUris = "redirectUris";
@@ -14,4 +14,5 @@ public static class AuthorityClientMetadataKeys
public const string Tenant = "tenant";
public const string Project = "project";
public const string ServiceIdentity = "serviceIdentity";
public const string RequiresAirGapSealConfirmation = "requiresAirgapSealConfirmation";
}

View File

@@ -23,5 +23,6 @@ public static class AuthorityMongoDefaults
public const string Revocations = "authority_revocations";
public const string RevocationState = "authority_revocation_state";
public const string Invites = "authority_bootstrap_invites";
public const string AirgapAudit = "authority_airgap_audit";
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an audit record for an air-gapped bundle import operation.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityAirgapAuditDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("tenant")]
public string Tenant { get; set; } = string.Empty;
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("username")]
[BsonIgnoreIfNull]
public string? Username { get; set; }
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("bundleId")]
public string BundleId { get; set; } = string.Empty;
[BsonElement("status")]
public string Status { get; set; } = "unknown";
[BsonElement("reason")]
[BsonIgnoreIfNull]
public string? Reason { get; set; }
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("properties")]
[BsonIgnoreIfNull]
public List<AuthorityAirgapAuditPropertyDocument>? Properties { get; set; }
}
/// <summary>
/// Represents an additional metadata entry captured for an air-gapped import audit record.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityAirgapAuditPropertyDocument
{
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("value")]
[BsonIgnoreIfNull]
public string? Value { get; set; }
}

View File

@@ -70,13 +70,17 @@ public sealed class AuthorityTokenDocument
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("incidentReason")]
[BsonIgnoreIfNull]
public string? IncidentReason { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]

View File

@@ -101,28 +101,36 @@ public static class ServiceCollectionExtensions
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityAirgapAuditCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
services.TryAddSingleton<IAuthorityAirgapAuditStore, AuthorityAirgapAuditStore>();
return services;
}

View File

@@ -0,0 +1,38 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityAirgapAuditCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
var indexModels = new[]
{
new CreateIndexModel<AuthorityAirgapAuditDocument>(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Tenant),
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
new CreateIndexOptions { Name = "airgap_audit_tenant_time" }),
new CreateIndexModel<AuthorityAirgapAuditDocument>(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Tenant),
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.BundleId),
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
new CreateIndexOptions { Name = "airgap_audit_bundle" }),
new CreateIndexModel<AuthorityAirgapAuditDocument>(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Status),
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
new CreateIndexOptions { Name = "airgap_audit_status" }),
new CreateIndexModel<AuthorityAirgapAuditDocument>(
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.TraceId),
new CreateIndexOptions { Name = "airgap_audit_trace", Sparse = true })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -15,7 +15,8 @@ internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigra
AuthorityMongoDefaults.Collections.Clients,
AuthorityMongoDefaults.Collections.Scopes,
AuthorityMongoDefaults.Collections.Tokens,
AuthorityMongoDefaults.Collections.LoginAttempts
AuthorityMongoDefaults.Collections.LoginAttempts,
AuthorityMongoDefaults.Collections.AirgapAudit
};
private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;

View File

@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityAirgapAuditStore : IAuthorityAirgapAuditStore
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private readonly IMongoCollection<AuthorityAirgapAuditDocument> collection;
private readonly ILogger<AuthorityAirgapAuditStore> logger;
public AuthorityAirgapAuditStore(
IMongoCollection<AuthorityAirgapAuditDocument> collection,
ILogger<AuthorityAirgapAuditStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(
AuthorityAirgapAuditDocument document,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug(
"Recorded airgap audit entry for bundle {BundleId} under tenant {Tenant}.",
document.BundleId,
document.Tenant);
}
public async ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(
AuthorityAirgapAuditQuery query,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(query);
if (string.IsNullOrWhiteSpace(query.Tenant))
{
return new AuthorityAirgapAuditQueryResult(Array.Empty<AuthorityAirgapAuditDocument>(), null);
}
var filterBuilder = Builders<AuthorityAirgapAuditDocument>.Filter;
var filter = filterBuilder.Eq(audit => audit.Tenant, query.Tenant.Trim());
if (!string.IsNullOrWhiteSpace(query.BundleId))
{
filter &= filterBuilder.Eq(audit => audit.BundleId, query.BundleId.Trim());
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
filter &= filterBuilder.Eq(audit => audit.Status, query.Status.Trim().ToLowerInvariant());
}
if (!string.IsNullOrWhiteSpace(query.TraceId))
{
filter &= filterBuilder.Eq(audit => audit.TraceId, query.TraceId.Trim());
}
if (!string.IsNullOrWhiteSpace(query.AfterId) && ObjectId.TryParse(query.AfterId, out var afterObjectId))
{
filter &= filterBuilder.Lt("_id", afterObjectId);
}
var limit = query.Limit <= 0 ? DefaultLimit : Math.Min(query.Limit, MaxLimit);
var options = new FindOptions<AuthorityAirgapAuditDocument>
{
Sort = Builders<AuthorityAirgapAuditDocument>.Sort.Descending("_id"),
Limit = limit
};
IAsyncCursor<AuthorityAirgapAuditDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
}
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
var nextCursor = documents.Count == limit ? documents[^1].Id : null;
return new AuthorityAirgapAuditQueryResult(documents, nextCursor);
}
}

View File

@@ -251,4 +251,58 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
return documents;
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(
string scope,
string tenant,
DateTimeOffset? issuedAfter,
int limit,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(scope))
{
throw new ArgumentException("Scope cannot be empty.", nameof(scope));
}
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("Tenant cannot be empty.", nameof(tenant));
}
var normalizedScope = scope.Trim();
var normalizedTenant = tenant.Trim().ToLowerInvariant();
var effectiveLimit = limit <= 0 ? 50 : Math.Min(limit, 500);
var filters = new List<FilterDefinition<AuthorityTokenDocument>>
{
Builders<AuthorityTokenDocument>.Filter.AnyEq(t => t.Scope, normalizedScope),
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant)
};
if (issuedAfter is DateTimeOffset issuedThreshold)
{
filters.Add(Builders<AuthorityTokenDocument>.Filter.Gte(t => t.CreatedAt, issuedThreshold));
}
var filter = Builders<AuthorityTokenDocument>.Filter.And(filters);
var options = new FindOptions<AuthorityTokenDocument>
{
Sort = Builders<AuthorityTokenDocument>.Sort.Descending(t => t.CreatedAt).Descending(t => t.TokenId),
Limit = effectiveLimit
};
IAsyncCursor<AuthorityTokenDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
}
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents;
}
}

View File

@@ -0,0 +1,51 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
/// <summary>
/// Abstraction for persisting and querying air-gapped import audit records.
/// </summary>
public interface IAuthorityAirgapAuditStore
{
ValueTask InsertAsync(
AuthorityAirgapAuditDocument document,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(
AuthorityAirgapAuditQuery query,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
}
/// <summary>
/// Query options for locating air-gapped import audit records.
/// </summary>
public sealed record AuthorityAirgapAuditQuery
{
public string Tenant { get; init; } = string.Empty;
public string? BundleId { get; init; }
public string? Status { get; init; }
public string? TraceId { get; init; }
/// <summary>
/// Continuation cursor (exclusive) using the Mongo document identifier.
/// </summary>
public string? AfterId { get; init; }
/// <summary>
/// Maximum number of documents to return. Defaults to 50 and capped at 200.
/// </summary>
public int Limit { get; init; } = 50;
}
/// <summary>
/// Result payload for air-gapped import audit queries.
/// </summary>
public sealed record AuthorityAirgapAuditQueryResult(
IReadOnlyList<AuthorityAirgapAuditDocument> Items,
string? NextCursor);

View File

@@ -28,6 +28,14 @@ public interface IAuthorityTokenStore
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(
string scope,
string tenant,
DateTimeOffset? issuedAfter,
int limit,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
}
public enum TokenUsageUpdateStatus

View File

@@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using Xunit;
using Microsoft.AspNetCore.TestHost;
namespace StellaOps.Authority.Tests.AdvisoryAi;
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
{
using var client = CreateClient(
configureOptions: options =>
{
options.AdvisoryAi.RemoteInference.Enabled = false;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
});
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_disabled", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_consent_required", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "other-profile"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("profile_not_allowed", body!["error"]);
}
[Fact]
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
var collection = database.GetCollection<BsonDocument>("authority_login_attempts");
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("logged", body!["status"]);
var expectedHash = ComputeSha256(payload.Prompt);
Assert.Equal(expectedHash, body["prompt_hash"]);
var doc = await collection.Find(Builders<BsonDocument>.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
var properties = ExtractProperties(doc);
Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
}
private HttpClient CreateClient(Action<StellaOpsAuthorityOptions>? configureOptions = null)
{
const string schemeName = "StellaOpsBearer";
var builder = factory.WithWebHostBuilder(hostBuilder =>
{
hostBuilder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = schemeName;
options.DefaultChallengeScheme = schemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
{
opts.Issuer ??= new Uri("https://authority.test");
if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
{
opts.Storage.ConnectionString = factory.ConnectionString;
}
if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
{
opts.Storage.DatabaseName = "authority-tests";
}
opts.AdvisoryAi.RemoteInference.Enabled = true;
opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
opts.Tenants.Clear();
opts.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
AdvisoryAi =
{
RemoteInference =
{
ConsentGranted = true,
ConsentVersion = "2025-10",
ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
ConsentedBy = "legal@example.com"
}
}
});
configureOptions?.Invoke(opts);
});
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
return client;
}
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
{
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
}
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
{
if (options.Tenants.Count == 0)
{
options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" });
}
var tenant = options.Tenants[0];
tenant.Id = "tenant-default";
tenant.DisplayName = "Tenant Default";
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z");
tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
}
private static string ComputeSha256(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static Dictionary<string, string> ExtractProperties(BsonDocument document)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (!document.TryGetValue("properties", out var propertiesValue))
{
return result;
}
foreach (var item in propertiesValue.AsBsonArray)
{
if (item is not BsonDocument property)
{
continue;
}
var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null;
var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null;
if (!string.IsNullOrWhiteSpace(name))
{
result[name] = value ?? string.Empty;
}
}
return result;
}
private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.")
{
return new RemoteInferencePayload(
TaskType: "summary",
Profile: profile,
ModelId: "gpt-4o-mini",
Prompt: prompt,
ContextDigest: "sha256:context",
OutputHash: "sha256:output",
TaskId: "task-123",
Metadata: new Dictionary<string, string>
{
["channel"] = "cli",
["env"] = "test"
});
}
private sealed record RemoteInferencePayload(
[property: JsonPropertyName("taskType")] string TaskType,
[property: JsonPropertyName("profile")] string Profile,
[property: JsonPropertyName("modelId")] string ModelId,
[property: JsonPropertyName("prompt")] string Prompt,
[property: JsonPropertyName("contextDigest")] string ContextDigest,
[property: JsonPropertyName("outputHash")] string OutputHash,
[property: JsonPropertyName("taskId")] string TaskId,
[property: JsonPropertyName("metadata")] IDictionary<string, string> Metadata);
}

View File

@@ -0,0 +1,143 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Authority.AdvisoryAi;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Authority.Tests.AdvisoryAi;
public sealed class AuthorityAdvisoryAiConsentEvaluatorTests
{
[Fact]
public void EvaluateTenant_ReturnsDisabled_WhenRemoteInferenceDisabled()
{
var options = CreateOptions();
options.AdvisoryAi.RemoteInference.Enabled = false;
var evaluator = CreateEvaluator(options);
var result = evaluator.EvaluateTenant("tenant-default");
Assert.False(result.Allowed);
Assert.Equal("remote_inference_disabled", result.ErrorCode);
}
[Fact]
public void EvaluateTenant_RequiresConsent_WhenConfigured()
{
var options = CreateOptions();
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
options.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
AdvisoryAi =
{
RemoteInference =
{
ConsentGranted = false
}
}
});
var evaluator = CreateEvaluator(options);
var result = evaluator.EvaluateTenant("tenant-default");
Assert.False(result.Allowed);
Assert.Equal("remote_inference_consent_required", result.ErrorCode);
}
[Fact]
public void EvaluateTenant_Allows_WhenConsentGranted()
{
var options = CreateOptions();
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
options.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
AdvisoryAi =
{
RemoteInference =
{
ConsentGranted = true,
ConsentVersion = "2025-10",
ConsentedAt = new DateTimeOffset(2025, 10, 31, 12, 34, 56, TimeSpan.Zero),
ConsentedBy = "legal@example.com"
}
}
});
var evaluator = CreateEvaluator(options);
var result = evaluator.EvaluateTenant("tenant-default");
Assert.True(result.Allowed);
Assert.Equal("2025-10", result.ConsentVersion);
Assert.Equal(new DateTimeOffset(2025, 10, 31, 12, 34, 56, TimeSpan.Zero), result.ConsentedAt);
Assert.Equal("legal@example.com", result.ConsentedBy);
}
[Fact]
public void TryNormalizeProfile_ComparesCaseInsensitively()
{
var options = CreateOptions();
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
var evaluator = CreateEvaluator(options);
var snapshot = evaluator.GetSnapshot();
Assert.True(snapshot.Enabled);
var normalized = evaluator.TryNormalizeProfile("CLOUD-OPENAI", out var profile);
Assert.True(normalized);
Assert.Equal("cloud-openai", profile);
}
private static AuthorityAdvisoryAiConsentEvaluator CreateEvaluator(StellaOpsAuthorityOptions options)
{
var monitor = new TestOptionsMonitor(options);
return new AuthorityAdvisoryAiConsentEvaluator(monitor);
}
private static StellaOpsAuthorityOptions CreateOptions()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
return options;
}
private sealed class TestOptionsMonitor : IOptionsMonitor<StellaOpsAuthorityOptions>
{
private StellaOpsAuthorityOptions current;
public TestOptionsMonitor(StellaOpsAuthorityOptions value)
{
current = value ?? throw new ArgumentNullException(nameof(value));
}
public StellaOpsAuthorityOptions CurrentValue => current;
public StellaOpsAuthorityOptions Get(string? name) => current;
public IDisposable OnChange(Action<StellaOpsAuthorityOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Tests.Infrastructure;
using Xunit;
namespace StellaOps.Authority.Tests.Airgap;
public sealed class AirgapAuditEndpointsTests : IClassFixture<AuthorityWebApplicationFactory>
{
private const string TenantId = "tenant-default";
private readonly AuthorityWebApplicationFactory factory;
public AirgapAuditEndpointsTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task PostAudit_ReturnsCreatedAndPersists()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z"));
using var client = CreateClient(timeProvider, scopes: $"{StellaOpsScopes.AirgapImport} {StellaOpsScopes.AirgapStatusRead}");
var collection = GetAuditCollection();
await collection.DeleteManyAsync(FilterDefinition<AuthorityAirgapAuditDocument>.Empty);
var request = new AirgapAuditRecordRequestDto
{
BundleId = "mirror-bundle-001",
Status = "COMPLETED",
Reason = "Initial air-gap import",
Metadata = new Dictionary<string, string?>
{
["digest"] = "sha256:abc123",
["operator"] = "ops-user"
}
};
var response = await client.PostAsJsonAsync("/authority/audit/airgap", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AirgapAuditRecordResponseDto>();
Assert.NotNull(body);
Assert.Equal("mirror-bundle-001", body!.BundleId);
Assert.Equal("completed", body.Status);
Assert.Equal(TenantId, body.Tenant);
Assert.Equal("test-client", body.ClientId);
Assert.Equal(timeProvider.GetUtcNow(), body.OccurredAt);
Assert.Equal("sha256:abc123", body.Metadata["digest"]);
var stored = await collection.Find(Builders<AuthorityAirgapAuditDocument>.Filter.Eq(d => d.BundleId, "mirror-bundle-001")).SingleAsync();
Assert.Equal("completed", stored.Status);
Assert.Equal(TenantId, stored.Tenant);
Assert.Equal("test-client", stored.ClientId);
Assert.Contains(stored.Properties!, property => property.Name == "digest" && property.Value == "sha256:abc123");
var listResponse = await client.GetFromJsonAsync<AirgapAuditListResponseDto>("/authority/audit/airgap");
Assert.NotNull(listResponse);
var item = Assert.Single(listResponse!.Items);
Assert.Equal(body.Id, item.Id);
Assert.Equal("mirror-bundle-001", item.BundleId);
}
[Fact]
public async Task GetAudit_RequiresStatusScope()
{
using var client = CreateClient(new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z")), scopes: StellaOpsScopes.AirgapImport);
var response = await client.GetAsync("/authority/audit/airgap");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task PostAudit_RequiresImportScope()
{
using var client = CreateClient(new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z")), scopes: StellaOpsScopes.AirgapStatusRead);
var request = new AirgapAuditRecordRequestDto
{
BundleId = "mirror-bundle-002",
Status = "completed"
};
var response = await client.PostAsJsonAsync("/authority/audit/airgap", request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task GetAudit_ReturnsBadRequest_WhenTenantHeaderMissing()
{
using var client = CreateClient(new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z")),
scopes: StellaOpsScopes.AirgapStatusRead,
includeAuthorityTenantHeader: false);
var response = await client.GetAsync("/authority/audit/airgap");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetAudit_PaginatesAndFilters()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z"));
using var client = CreateClient(timeProvider, scopes: $"{StellaOpsScopes.AirgapImport} {StellaOpsScopes.AirgapStatusRead}");
var collection = GetAuditCollection();
await collection.DeleteManyAsync(FilterDefinition<AuthorityAirgapAuditDocument>.Empty);
await PostAsync(client, "bundle-A", "completed", timeProvider);
timeProvider.Advance(TimeSpan.FromMinutes(1));
await PostAsync(client, "bundle-B", "completed", timeProvider);
timeProvider.Advance(TimeSpan.FromMinutes(1));
await PostAsync(client, "bundle-C", "failed", timeProvider, reason: "validation error");
var firstPage = await client.GetFromJsonAsync<AirgapAuditListResponseDto>("/authority/audit/airgap?pageSize=2");
Assert.NotNull(firstPage);
Assert.Equal(2, firstPage!.Items.Count);
Assert.Equal("bundle-C", firstPage.Items[0].BundleId);
Assert.Equal("bundle-B", firstPage.Items[1].BundleId);
Assert.False(string.IsNullOrWhiteSpace(firstPage.NextCursor));
var cursor = firstPage.NextCursor;
var secondPage = await client.GetFromJsonAsync<AirgapAuditListResponseDto>($"/authority/audit/airgap?pageSize=2&cursor={cursor}");
Assert.NotNull(secondPage);
Assert.Single(secondPage!.Items);
Assert.Equal("bundle-A", secondPage.Items[0].BundleId);
Assert.Null(secondPage.NextCursor);
var filtered = await client.GetFromJsonAsync<AirgapAuditListResponseDto>("/authority/audit/airgap?bundleId=bundle-B");
Assert.NotNull(filtered);
var entry = Assert.Single(filtered!.Items);
Assert.Equal("bundle-B", entry.BundleId);
Assert.Equal("completed", entry.Status);
}
private static async Task PostAsync(HttpClient client, string bundleId, string status, FakeTimeProvider timeProvider, string? reason = null)
{
var request = new AirgapAuditRecordRequestDto
{
BundleId = bundleId,
Status = status,
Reason = reason,
Metadata = new Dictionary<string, string?> { ["sequence"] = timeProvider.GetUtcNow().ToUnixTimeSeconds().ToString() }
};
var response = await client.PostAsJsonAsync("/authority/audit/airgap", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
private HttpClient CreateClient(
FakeTimeProvider timeProvider,
string scopes,
bool includeAuthorityTenantHeader = true,
bool includeTestTenantHeader = true)
{
const string schemeName = "StellaOpsBearer";
var builder = factory.WithWebHostBuilder(hostBuilder =>
{
hostBuilder.ConfigureTestServices(services =>
{
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = schemeName;
options.DefaultChallengeScheme = schemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
if (!string.IsNullOrWhiteSpace(scopes))
{
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
}
if (includeTestTenantHeader)
{
client.DefaultRequestHeaders.Add("X-Test-Tenant", TenantId);
}
if (includeAuthorityTenantHeader)
{
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, TenantId);
}
return client;
}
private IMongoCollection<AuthorityAirgapAuditDocument> GetAuditCollection()
{
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
}
private sealed record AirgapAuditRecordRequestDto
{
public string BundleId { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? Reason { get; init; }
public Dictionary<string, string?>? Metadata { get; init; }
}
private sealed record AirgapAuditRecordResponseDto
{
public string Id { get; init; } = string.Empty;
public string Tenant { get; init; } = string.Empty;
public string BundleId { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? Reason { get; init; }
public string? TraceId { get; init; }
public string? SubjectId { get; init; }
public string? Username { get; init; }
public string? DisplayName { get; init; }
public string? ClientId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public Dictionary<string, string?> Metadata { get; init; } = new(StringComparer.Ordinal);
}
private sealed record AirgapAuditListResponseDto
{
public List<AirgapAuditListItemDto> Items { get; init; } = new();
public string? NextCursor { get; init; }
}
private sealed record AirgapAuditListItemDto
{
public string Id { get; init; } = string.Empty;
public string Tenant { get; init; } = string.Empty;
public string BundleId { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? Reason { get; init; }
public string? TraceId { get; init; }
public string? SubjectId { get; init; }
public string? Username { get; init; }
public string? DisplayName { get; init; }
public string? ClientId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public Dictionary<string, string?> Metadata { get; init; } = new(StringComparer.Ordinal);
}
}

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -51,10 +52,14 @@ public sealed class ConsoleEndpointsTests
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.tenants.read", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
Assert.Contains("tenant.resolved", audit.Properties.Select(property => property.Name));
var events = sink.Events;
var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize"));
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.tenants.read"));
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name));
Assert.Equal(2, events.Count);
}
[Fact]
@@ -75,7 +80,10 @@ public sealed class ConsoleEndpointsTests
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(sink.Events);
var authEvent = Assert.Single(sink.Events);
Assert.Equal("authority.resource.authorize", authEvent.EventType);
Assert.Equal(AuthEventOutcome.Success, authEvent.Outcome);
Assert.DoesNotContain(sink.Events, evt => evt.EventType.StartsWith("authority.console.", System.StringComparison.Ordinal));
}
[Fact]
@@ -97,7 +105,11 @@ public sealed class ConsoleEndpointsTests
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(sink.Events);
var authEvent = Assert.Single(sink.Events);
Assert.Equal("authority.resource.authorize", authEvent.EventType);
Assert.Equal(AuthEventOutcome.Success, authEvent.Outcome);
Assert.Null(authEvent.Reason);
Assert.DoesNotContain(sink.Events, evt => evt.EventType.StartsWith("authority.console.", System.StringComparison.Ordinal));
}
[Fact]
@@ -133,9 +145,13 @@ public sealed class ConsoleEndpointsTests
Assert.Equal("console-user", json.RootElement.GetProperty("username").GetString());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.profile.read", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
var events = sink.Events;
var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize"));
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.profile.read"));
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
[Fact]
@@ -167,9 +183,13 @@ public sealed class ConsoleEndpointsTests
Assert.False(json.RootElement.GetProperty("active").GetBoolean());
Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.token.introspect", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
var events = sink.Events;
var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize"));
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.token.introspect"));
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
private static ClaimsPrincipal CreatePrincipal(
@@ -336,4 +356,4 @@ internal static class HostTestClientExtensions
internal static class TestAuthenticationDefaults
{
public const string AuthenticationScheme = "AuthorityConsoleTests";
}
}

View File

@@ -1,10 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Mongo2Go;
using Xunit;
@@ -13,10 +14,35 @@ namespace StellaOps.Authority.Tests.Infrastructure;
public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
private readonly string tempContentRoot;
private const string IssuerKey = "STELLAOPS_AUTHORITY_AUTHORITY__ISSUER";
private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION";
private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING";
private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME";
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
public AuthorityWebApplicationFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
tempContentRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "stellaops-authority-tests", Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(tempContentRoot);
var repositoryRoot = LocateRepositoryRoot();
var openApiSource = Path.Combine(repositoryRoot, "src", "Api", "StellaOps.Api.OpenApi", "authority", "openapi.yaml");
var openApiDestination = Path.Combine(tempContentRoot, "OpenApi", "authority.yaml");
Directory.CreateDirectory(Path.GetDirectoryName(openApiDestination)!);
File.Copy(openApiSource, openApiDestination, overwrite: true);
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
}
public string ConnectionString => mongoRunner.ConnectionString;
@@ -24,25 +50,85 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseContentRoot(tempContentRoot);
builder.ConfigureAppConfiguration((_, configuration) =>
{
var settings = new Dictionary<string, string?>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["Authority:Storage:DatabaseName"] = "authority-tests",
["Authority:Signing:Enabled"] = "false"
};
configuration.AddInMemoryCollection(settings);
["Authority:Signing:Enabled"] = "false",
["Authority:Notifications:AckTokens:Enabled"] = "false",
["Authority:Notifications:Webhooks:Enabled"] = "false"
});
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["Authority:Storage:DatabaseName"] = "authority-tests",
["Authority:Signing:Enabled"] = "false",
["Authority:Notifications:AckTokens:Enabled"] = "false",
["Authority:Notifications:Webhooks:Enabled"] = "false"
});
});
return base.CreateHost(builder);
}
public Task InitializeAsync() => Task.CompletedTask;
private static string LocateRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = directory.FullName;
if (File.Exists(Path.Combine(candidate, "README.md")) && Directory.Exists(Path.Combine(candidate, "src")))
{
return candidate;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Failed to locate repository root for Authority tests.");
}
public Task DisposeAsync()
{
mongoRunner.Dispose();
Environment.SetEnvironmentVariable(IssuerKey, null);
Environment.SetEnvironmentVariable(SchemaVersionKey, null);
Environment.SetEnvironmentVariable(StorageConnectionKey, null);
Environment.SetEnvironmentVariable(StorageDatabaseKey, null);
Environment.SetEnvironmentVariable(SigningEnabledKey, null);
Environment.SetEnvironmentVariable(AckTokensEnabledKey, null);
Environment.SetEnvironmentVariable(WebhooksEnabledKey, null);
try
{
if (System.IO.Directory.Exists(tempContentRoot))
{
System.IO.Directory.Delete(tempContentRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Tests.Infrastructure;
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestAuth";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues)
? tenantValues.ToString()
: "tenant-default";
var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues)
? scopeValues.ToString()
: StellaOpsScopes.AdvisoryAiOperate;
var claims = new List<Claim>
{
new Claim(StellaOpsClaimTypes.ClientId, "test-client")
};
if (!string.IsNullOrWhiteSpace(tenantHeader) &&
!string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim()));
}
var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var scope in scopes)
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
}
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,195 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Notifications;
using StellaOps.Authority.Notifications.Ack;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using Xunit;
namespace StellaOps.Authority.Tests.Notifications;
public sealed class AuthorityAckTokenIssuerTests
{
[Fact]
public async Task IssueAsync_ProducesDeterministicEnvelope()
{
var tempDir = Directory.CreateTempSubdirectory("ack-token-issuer").FullName;
var keyRelative = "ack.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, keyRelative));
var options = BuildOptions(tempDir, keyRelative);
using var provider = BuildProvider(tempDir, options);
var issuer = provider.GetRequiredService<AuthorityAckTokenIssuer>();
var verifier = provider.GetRequiredService<AuthorityAckTokenVerifier>();
var request = new AckTokenIssueRequest
{
Tenant = "tenant-dev",
NotificationId = "notif-123",
DeliveryId = "delivery-456",
Channel = "slack",
WebhookUrl = "https://hooks.slack.com/services/T000/B000/XXXX",
Actions = new[] { "ack", "resolve" },
Metadata = new() { ["priority"] = "high" }
};
var result = await issuer.IssueAsync(request, requesterHasEscalateScope: false, cancellationToken: default);
Assert.Equal("application/vnd.stellaops.notify-ack-token+json", result.Envelope.PayloadType);
Assert.NotNull(result.Envelope.Payload);
Assert.Single(result.Envelope.Signatures);
var verification = await verifier.VerifyAsync(result.Envelope, "ack", request.Tenant, default);
Assert.Equal(request.Tenant, verification.Payload.Tenant);
Assert.Equal(request.NotificationId, verification.Payload.NotificationId);
Assert.Contains("ack", verification.Payload.Actions);
Assert.Contains("resolve", verification.Payload.Actions);
Assert.False(verification.Payload.EscalationAllowed);
Assert.True(verification.SignatureValid);
}
finally
{
try
{
Directory.Delete(tempDir, true);
}
catch
{
// ignore
}
}
}
[Fact]
public async Task VerifyAsync_Throws_WhenActionNotPermitted()
{
var tempDir = Directory.CreateTempSubdirectory("ack-token-verify").FullName;
var keyRelative = "ack.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, keyRelative));
var options = BuildOptions(tempDir, keyRelative);
using var provider = BuildProvider(tempDir, options);
var issuer = provider.GetRequiredService<AuthorityAckTokenIssuer>();
var verifier = provider.GetRequiredService<AuthorityAckTokenVerifier>();
var request = new AckTokenIssueRequest
{
Tenant = "tenant-dev",
NotificationId = "notif-789",
DeliveryId = "delivery-abc",
Channel = "email",
WebhookUrl = "https://hooks.slack.com/services/T000/B000/YYYY",
Actions = new[] { "ack" }
};
var result = await issuer.IssueAsync(request, requesterHasEscalateScope: false, cancellationToken: default);
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(result.Envelope, "escalate", request.Tenant, default));
}
finally
{
try
{
Directory.Delete(tempDir, true);
}
catch
{
// ignore
}
}
}
private static StellaOpsAuthorityOptions BuildOptions(string basePath, string activeKey)
{
return new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Notifications =
{
AckTokens =
{
Enabled = true,
ActiveKeyId = "ack-key",
KeyPath = activeKey,
Algorithm = SignatureAlgorithms.Es256,
KeySource = "file",
KeyUse = "notify-ack",
DefaultLifetime = TimeSpan.FromMinutes(10),
MaxLifetime = TimeSpan.FromMinutes(30)
},
Webhooks =
{
Enabled = true,
AllowedHosts = { "hooks.slack.com" }
},
Escalation =
{
Scope = "notify.escalate",
RequireAdminScope = true
}
}
};
}
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);
services.AddMemoryCache();
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
services.AddSingleton<AuthorityJwksService>();
services.AddSingleton<AuthorityAckTokenKeyManager>();
services.AddSingleton<AuthorityWebhookAllowlistEvaluator>();
services.AddSingleton<AuthorityAckTokenIssuer>();
services.AddSingleton<AuthorityAckTokenVerifier>();
return services.BuildServiceProvider();
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
EnvironmentName = Environments.Development;
ApplicationName = "StellaOps.Authority.Tests";
}
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Notifications.Ack;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using Xunit;
namespace StellaOps.Authority.Tests.Notifications;
public sealed class AuthorityAckTokenKeyManagerTests
{
[Fact]
public void Rotate_ReplacesActiveKeyAndRetiresPrevious()
{
var tempDir = Directory.CreateTempSubdirectory("ack-key-tests").FullName;
var key1Relative = "ack-key-1.pem";
var key2Relative = "ack-key-2.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, key1Relative));
var options = BuildOptions(tempDir, key1Relative);
using var provider = BuildProvider(tempDir, options);
var manager = provider.GetRequiredService<AuthorityAckTokenKeyManager>();
var jwksService = provider.GetRequiredService<AuthorityJwksService>();
var initialKeys = jwksService.Get();
var initialKey = Assert.Single(initialKeys.Response.Keys);
Assert.Equal("notify-ack", initialKey.Use);
Assert.Equal("ack-key-1", initialKey.Kid);
Assert.Equal("active", initialKey.Status);
CreateEcPrivateKey(Path.Combine(tempDir, key2Relative));
var rotation = manager.Rotate(new SigningRotationRequest
{
KeyId = "ack-key-2",
Location = key2Relative
});
Assert.Equal("ack-key-2", rotation.ActiveKeyId);
Assert.Equal("ack-key-1", rotation.PreviousKeyId);
Assert.Contains("ack-key-1", rotation.RetiredKeyIds);
Assert.Equal("ack-key-2", options.Notifications.AckTokens.ActiveKeyId);
var retiredEntry = Assert.Single(options.Notifications.AckTokens.AdditionalKeys);
Assert.Equal("ack-key-1", retiredEntry.KeyId);
Assert.Equal(key1Relative, retiredEntry.Path);
var postRotation = jwksService.Get();
Assert.Equal(2, postRotation.Response.Keys.Count);
Assert.Contains(postRotation.Response.Keys, key => key.Kid == "ack-key-2" && key.Status == "active");
Assert.Contains(postRotation.Response.Keys, key => key.Kid == "ack-key-1" && key.Status == "retired");
}
finally
{
try
{
Directory.Delete(tempDir, true);
}
catch
{
// ignore cleanup failure
}
}
}
private static StellaOpsAuthorityOptions BuildOptions(string basePath, string activeKey)
{
return new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Notifications =
{
AckTokens =
{
Enabled = true,
ActiveKeyId = "ack-key-1",
KeyPath = activeKey,
Algorithm = SignatureAlgorithms.Es256,
KeySource = "file",
KeyUse = "notify-ack",
DefaultLifetime = TimeSpan.FromMinutes(15),
MaxLifetime = TimeSpan.FromMinutes(30)
},
Webhooks =
{
Enabled = true,
AllowedHosts = { "hooks.slack.com" }
}
}
};
}
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);
services.AddMemoryCache();
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
services.AddSingleton<AuthorityJwksService>();
services.AddSingleton<AuthorityAckTokenKeyManager>();
return services.BuildServiceProvider();
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportECPrivateKeyPem();
File.WriteAllText(path, pem);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
EnvironmentName = Environments.Development;
ApplicationName = "StellaOps.Authority.Tests";
}
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Notifications;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Authority.Tests.Notifications;
public sealed class AuthorityWebhookAllowlistEvaluatorTests
{
[Fact]
public void EnsureAllowed_AllowsExactHost()
{
var options = CreateOptions();
var evaluator = new AuthorityWebhookAllowlistEvaluator(Options.Create(options));
var uri = new Uri("https://hooks.slack.com/services/T000/B000/ZZZ");
evaluator.EnsureAllowed(uri);
}
[Fact]
public void EnsureAllowed_AllowsWildcardSuffix()
{
var options = CreateOptions();
options.Notifications.Webhooks.AllowedHosts.Add("*.pagerduty.com");
var evaluator = new AuthorityWebhookAllowlistEvaluator(Options.Create(options));
var uri = new Uri("https://events.pagerduty.com/integration");
evaluator.EnsureAllowed(uri);
}
[Fact]
public void EnsureAllowed_ThrowsForDisallowedHost()
{
var options = CreateOptions();
var evaluator = new AuthorityWebhookAllowlistEvaluator(Options.Create(options));
var uri = new Uri("https://example.com/webhook");
Assert.Throws<InvalidOperationException>(() => evaluator.EnsureAllowed(uri));
}
private static StellaOpsAuthorityOptions CreateOptions()
{
return new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Notifications =
{
Webhooks =
{
Enabled = true,
AllowedHosts = { "hooks.slack.com" }
}
}
};
}
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.Notifications;
public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
[Fact]
public async Task Rotate_ReturnsOk_AndEmitsAuditEvent()
{
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success");
try
{
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
CreateEcPrivateKey(key1Path);
CreateEcPrivateKey(key2Path);
var sink = new RecordingAuthEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z"));
using var app = factory.WithWebHostBuilder(host =>
{
host.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Notifications:AckTokens:Enabled"] = "true",
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
["Authority:Notifications:AckTokens:KeySource"] = "file",
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
["Authority:Notifications:Webhooks:Enabled"] = "true",
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com",
["Authority:Notifications:Escalation:Scope"] = "notify.escalate",
["Authority:Notifications:Escalation:RequireAdminScope"] = "true"
});
});
host.ConfigureTestServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
{
keyId = "ack-key-2",
location = key2Path
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<AckRotateResponse>();
Assert.NotNull(payload);
Assert.Equal("ack-key-2", payload!.ActiveKeyId);
Assert.Equal("ack-key-1", payload.PreviousKeyId);
var rotationEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "notify.ack.key_rotated"));
Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome);
Assert.Contains(rotationEvent.Properties, property =>
string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(tempDir.FullName);
}
}
[Fact]
public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure()
{
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure");
try
{
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
CreateEcPrivateKey(key1Path);
CreateEcPrivateKey(key2Path);
var sink = new RecordingAuthEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z"));
using var app = factory.WithWebHostBuilder(host =>
{
host.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Notifications:AckTokens:Enabled"] = "true",
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
["Authority:Notifications:AckTokens:KeySource"] = "file",
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
["Authority:Notifications:Webhooks:Enabled"] = "true",
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com"
});
});
host.ConfigureTestServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
{
location = key2Path
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var failureEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "notify.ack.key_rotation_failed"));
Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome);
Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase);
}
finally
{
TryDeleteDirectory(tempDir.FullName);
}
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// Ignore cleanup failures in tests.
}
}
private sealed record AckRotateResponse(
string ActiveKeyId,
string? Provider,
string? Source,
string? Location,
string? PreviousKeyId,
IReadOnlyCollection<string> RetiredKeyIds);
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly ConcurrentQueue<AuthEventRecord> events = new();
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
events.Enqueue(record);
return ValueTask.CompletedTask;
}
}
}

View File

@@ -48,8 +48,10 @@ public sealed class OpenApiDiscoveryEndpointTests : IClassFixture<AuthorityWebAp
var grantsHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Grants"));
Assert.Contains("authorization_code", grantsHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
var scopesHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Scopes"));
Assert.Contains("policy:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
var scopesHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Scopes"));
Assert.Contains("policy:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
Assert.Contains("advisory-ai:view", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
Assert.Contains("airgap:status:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
[Fact]

View File

@@ -0,0 +1,63 @@
using System.Linq;
using System.Net;
using System.Text.Json;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/.well-known/openid-configuration").ConfigureAwait(false);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode));
var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes);
Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes);
Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes);
Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode));
Assert.False(remoteNode.GetProperty("enabled").GetBoolean());
Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean());
var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray();
Assert.Empty(profiles);
Assert.True(root.TryGetProperty("stellaops_airgap_scopes_supported", out var airgapNode));
var airgapScopes = airgapNode.EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.AirgapSeal, airgapScopes);
Assert.Contains(StellaOpsScopes.AirgapImport, airgapScopes);
Assert.Contains(StellaOpsScopes.AirgapStatusRead, airgapScopes);
Assert.True(root.TryGetProperty("stellaops_observability_scopes_supported", out var observabilityNode));
var observabilityScopes = observabilityNode.EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.ObservabilityRead, observabilityScopes);
Assert.Contains(StellaOpsScopes.TimelineRead, observabilityScopes);
Assert.Contains(StellaOpsScopes.TimelineWrite, observabilityScopes);
Assert.Contains(StellaOpsScopes.EvidenceCreate, observabilityScopes);
Assert.Contains(StellaOpsScopes.EvidenceRead, observabilityScopes);
Assert.Contains(StellaOpsScopes.EvidenceHold, observabilityScopes);
Assert.Contains(StellaOpsScopes.AttestRead, observabilityScopes);
Assert.Contains(StellaOpsScopes.ObservabilityIncident, observabilityScopes);
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public sealed class LegacyAuthDeprecationTests : IClassFixture<AuthorityWebApplicationFactory>
{
private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero)
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
.ToString("O", CultureInfo.InvariantCulture);
private readonly AuthorityWebApplicationFactory factory;
public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory)
=> this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
[Fact]
public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders()
{
using var client = factory.CreateClient();
using var response = await client.PostAsync(
"/oauth/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials"
})).ConfigureAwait(false);
Assert.NotNull(response);
Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues));
Assert.Contains(ExpectedDeprecationHeader, deprecationValues);
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues));
Assert.Contains(ExpectedSunsetHeader, sunsetValues);
Assert.True(response.Headers.TryGetValues("Warning", out var warningValues));
Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase));
Assert.True(response.Headers.TryGetValues("Link", out var linkValues));
Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task LegacyTokenEndpoint_EmitsAuditEvent()
{
var sink = new RecordingAuthEventSink();
using var customFactory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
});
});
using var client = customFactory.CreateClient();
using var response = await client.PostAsync(
"/oauth/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials"
})).ConfigureAwait(false);
Assert.NotNull(response);
var record = Assert.Single(sink.Events);
Assert.Equal("authority.api.legacy_endpoint", record.EventType);
Assert.Contains(record.Properties, property =>
string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal));
Assert.Contains(record.Properties, property =>
string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, "/token", StringComparison.Ordinal));
Assert.Contains(record.Properties, property =>
string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal));
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly ConcurrentQueue<AuthEventRecord> events = new();
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
events.Enqueue(record);
return ValueTask.CompletedTask;
}
}
}

View File

@@ -108,19 +108,21 @@ public sealed class VulnPermalinkServiceTests
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(timeProvider);
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
services.AddSingleton<AuthoritySigningKeyManager>();
services.AddSingleton<VulnPermalinkService>();
return services.BuildServiceProvider();
}
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(timeProvider);
services.AddMemoryCache();
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
services.AddSingleton<AuthorityJwksService>();
services.AddSingleton<AuthoritySigningKeyManager>();
services.AddSingleton<VulnPermalinkService>();
return services.BuildServiceProvider();
}
private static void CreateEcPrivateKey(string path)
{

View File

@@ -13,6 +13,8 @@ using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using Xunit;
using CryptoProvider = StellaOps.Cryptography.ICryptoProvider;
using CryptoProviderRegistry = StellaOps.Cryptography.ICryptoProviderRegistry;
namespace StellaOps.Authority.Tests.Signing;
@@ -102,24 +104,24 @@ public sealed class AuthorityJwksServiceTests
};
}
private sealed class TestRegistry : ICryptoProviderRegistry
private sealed class TestRegistry : CryptoProviderRegistry
{
private readonly IReadOnlyCollection<ICryptoProvider> providers;
private readonly IReadOnlyCollection<CryptoProvider> providers;
public TestRegistry(ICryptoProvider provider)
public TestRegistry(CryptoProvider provider)
{
providers = new[] { provider };
}
public IReadOnlyCollection<ICryptoProvider> Providers => providers;
public IReadOnlyCollection<CryptoProvider> Providers => providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
public bool TryResolve(string preferredProvider, out CryptoProvider provider)
{
provider = providers.First();
return true;
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
public CryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> providers.First();
public CryptoSignerResolution ResolveSigner(
@@ -133,7 +135,7 @@ public sealed class AuthorityJwksServiceTests
}
}
private sealed class TestCryptoProvider : ICryptoProvider
private sealed class TestCryptoProvider : CryptoProvider
{
private readonly Dictionary<string, TestKey> keys = new(StringComparer.OrdinalIgnoreCase);
private int counter;
@@ -197,10 +199,11 @@ public sealed class AuthorityJwksServiceTests
public CryptoSigningKey ToSigningKey()
{
var ecParameters = Parameters;
return new CryptoSigningKey(
new CryptoKeyReference(KeyId, "test"),
SignatureAlgorithms.Es256,
in Parameters,
in ecParameters,
DateTimeOffset.UtcNow,
metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace StellaOps.Authority.AdvisoryAi;
internal sealed class AdvisoryAiRemoteInferenceLogRequest
{
public string? TaskId { get; init; }
public string? TaskType { get; init; }
public string? Profile { get; init; }
public string? ModelId { get; init; }
public string? Prompt { get; init; }
public string? ContextDigest { get; init; }
public string? OutputHash { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Configuration;
namespace StellaOps.Authority.AdvisoryAi;
internal interface IAuthorityAdvisoryAiConsentEvaluator
{
AdvisoryAiRemoteInferenceSnapshot GetSnapshot();
bool TryNormalizeProfile(string? profile, out string normalizedProfile);
AuthorityTenantRemoteInferenceConsentResult EvaluateTenant(string? tenantId);
}
internal sealed class AuthorityAdvisoryAiConsentEvaluator : IAuthorityAdvisoryAiConsentEvaluator
{
private static readonly StringComparer TenantComparer = StringComparer.Ordinal;
private static readonly StringComparer ProfileComparer = StringComparer.OrdinalIgnoreCase;
private readonly IOptionsMonitor<StellaOpsAuthorityOptions> optionsMonitor;
public AuthorityAdvisoryAiConsentEvaluator(IOptionsMonitor<StellaOpsAuthorityOptions> optionsMonitor)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
}
public AdvisoryAiRemoteInferenceSnapshot GetSnapshot()
{
var options = optionsMonitor.CurrentValue ?? throw new InvalidOperationException("Authority configuration is not available.");
var remote = options.AdvisoryAi.RemoteInference;
IReadOnlyList<string> allowedProfiles = remote.AllowedProfiles.Count == 0
? Array.Empty<string>()
: remote.AllowedProfiles
.Where(static profile => !string.IsNullOrWhiteSpace(profile))
.Select(static profile => profile.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new AdvisoryAiRemoteInferenceSnapshot(remote.Enabled, remote.RequireTenantConsent, allowedProfiles);
}
public bool TryNormalizeProfile(string? profile, out string normalizedProfile)
{
normalizedProfile = string.Empty;
if (string.IsNullOrWhiteSpace(profile))
{
return false;
}
var candidate = profile.Trim();
var snapshot = GetSnapshot();
if (!snapshot.Enabled)
{
return false;
}
if (snapshot.AllowedProfiles.Count == 0)
{
normalizedProfile = candidate;
return true;
}
foreach (var allowed in snapshot.AllowedProfiles)
{
if (ProfileComparer.Equals(candidate, allowed))
{
normalizedProfile = allowed;
return true;
}
}
return false;
}
public AuthorityTenantRemoteInferenceConsentResult EvaluateTenant(string? tenantId)
{
var options = optionsMonitor.CurrentValue ?? throw new InvalidOperationException("Authority configuration is not available.");
var remote = options.AdvisoryAi.RemoteInference;
if (!remote.Enabled)
{
return AuthorityTenantRemoteInferenceConsentResult.CreateDisabled();
}
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
? null
: tenantId.Trim().ToLowerInvariant();
if (!remote.RequireTenantConsent)
{
return AuthorityTenantRemoteInferenceConsentResult.CreateAllowed(null, null, null);
}
if (string.IsNullOrWhiteSpace(normalizedTenant))
{
return AuthorityTenantRemoteInferenceConsentResult.CreateDenied(
"tenant_missing",
"Token is missing tenant claim required for remote inference.");
}
var tenant = options.Tenants.FirstOrDefault(t => TenantComparer.Equals(t.Id, normalizedTenant));
if (tenant is null)
{
return AuthorityTenantRemoteInferenceConsentResult.CreateDenied(
"tenant_unknown",
$"Tenant '{normalizedTenant}' is not registered.");
}
var consent = tenant.AdvisoryAi.RemoteInference;
if (!consent.ConsentGranted)
{
return AuthorityTenantRemoteInferenceConsentResult.CreateDenied(
"remote_inference_consent_required",
"Tenant must record remote inference consent before remote inference can be invoked.");
}
return AuthorityTenantRemoteInferenceConsentResult.CreateAllowed(
consent.ConsentVersion,
consent.ConsentedAt,
consent.ConsentedBy);
}
}
internal readonly record struct AdvisoryAiRemoteInferenceSnapshot(
bool Enabled,
bool RequireTenantConsent,
IReadOnlyList<string> AllowedProfiles);
internal sealed record AuthorityTenantRemoteInferenceConsentResult(
bool Allowed,
string? ErrorCode,
string? ErrorMessage,
string? ConsentVersion,
DateTimeOffset? ConsentedAt,
string? ConsentedBy)
{
public static AuthorityTenantRemoteInferenceConsentResult CreateDisabled() =>
new(false, "remote_inference_disabled", "Remote inference is disabled by configuration.", null, null, null);
public static AuthorityTenantRemoteInferenceConsentResult CreateDenied(string errorCode, string errorMessage) =>
new(false, errorCode, errorMessage, null, null, null);
public static AuthorityTenantRemoteInferenceConsentResult CreateAllowed(string? consentVersion, DateTimeOffset? consentedAt, string? consentedBy) =>
new(true, null, null, consentVersion, consentedAt, consentedBy);
}

View File

@@ -0,0 +1,321 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Mime;
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Console;
using System.Linq;
using StellaOps.Auth.ServerIntegration;
namespace StellaOps.Authority.Airgap;
internal static class AirgapAuditEndpointExtensions
{
private static readonly HashSet<string> AllowedStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"started",
"completed",
"failed",
"replayed",
"validated"
};
private const int MaxBundleIdLength = 256;
private const int MaxReasonLength = 512;
private const int MaxMetadataEntries = 16;
private const int MaxMetadataKeyLength = 64;
private const int MaxMetadataValueLength = 512;
private const int DefaultPageSize = 50;
private const int MaxPageSize = 200;
public static void MapAirgapAuditEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/authority/audit/airgap")
.RequireAuthorization()
.WithTags("AuthorityAirgapAudit");
group.AddEndpointFilter(new TenantHeaderFilter());
group.MapGet("/", GetAuditAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead))
.WithName("GetAirgapAudit")
.WithSummary("List air-gapped bundle import audit records for the current tenant.")
.Produces<AirgapAuditListResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapPost("/", RecordAuditAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapImport))
.WithName("RecordAirgapAudit")
.WithSummary("Record an audit entry for an air-gapped bundle import action.")
.Accepts<AirgapAuditRecordRequest>(MediaTypeNames.Application.Json)
.Produces<AirgapAuditRecordResponse>(StatusCodes.Status201Created, MediaTypeNames.Application.Json)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
}
private static async Task<IResult> GetAuditAsync(
HttpContext httpContext,
IAuthorityAirgapAuditService auditService,
[FromQuery(Name = "bundleId")] string? bundleId,
[FromQuery(Name = "status")] string? status,
[FromQuery(Name = "traceId")] string? traceId,
[FromQuery(Name = "cursor")] string? cursor,
[FromQuery(Name = "pageSize")] int? pageSize,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(auditService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var normalizedStatus = NormalizeStatus(status);
if (normalizedStatus is not null && !AllowedStatuses.Contains(normalizedStatus))
{
return Results.BadRequest(new { error = "invalid_status", message = $"Status '{status}' is not allowed." });
}
var effectivePageSize = pageSize.GetValueOrDefault(DefaultPageSize);
if (effectivePageSize <= 0 || effectivePageSize > MaxPageSize)
{
return Results.BadRequest(new { error = "invalid_page_size", message = $"pageSize must be between 1 and {MaxPageSize}." });
}
var search = new AirgapAuditSearch(
Tenant: tenant,
BundleId: Normalize(bundleId),
Status: normalizedStatus,
TraceId: Normalize(traceId),
Cursor: Normalize(cursor),
Limit: effectivePageSize);
var page = await auditService.QueryAsync(search, cancellationToken).ConfigureAwait(false);
var response = new AirgapAuditListResponse(
page.Items.Select(MapListItem).ToArray(),
page.NextCursor);
return Results.Ok(response);
}
private static async Task<IResult> RecordAuditAsync(
HttpContext httpContext,
AirgapAuditRecordRequest request,
IAuthorityAirgapAuditService auditService,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(auditService);
if (request is null)
{
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
}
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var bundleId = Normalize(request.BundleId);
if (bundleId is null)
{
return Results.BadRequest(new { error = "bundle_id_required", message = "bundleId is required." });
}
if (bundleId.Length > MaxBundleIdLength)
{
return Results.BadRequest(new { error = "bundle_id_too_long", message = $"bundleId must be {MaxBundleIdLength} characters or fewer." });
}
var status = NormalizeStatus(request.Status);
if (status is null)
{
return Results.BadRequest(new { error = "status_required", message = "status is required." });
}
if (!AllowedStatuses.Contains(status))
{
return Results.BadRequest(new { error = "invalid_status", message = $"Status '{request.Status}' is not allowed." });
}
string? reason = Normalize(request.Reason);
if (reason is not null && reason.Length > MaxReasonLength)
{
return Results.BadRequest(new { error = "reason_too_long", message = $"reason must be {MaxReasonLength} characters or fewer." });
}
var metadata = ValidateMetadata(request.Metadata);
if (metadata is null)
{
return Results.BadRequest(new { error = "invalid_metadata", message = "metadata is invalid or exceeds limits." });
}
var subjectId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject));
var username = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername));
var displayName = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.Name));
var clientId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.ClientId));
var traceId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier;
var record = new AirgapAuditRecord(
Tenant: tenant,
BundleId: bundleId,
Status: status,
Reason: reason,
TraceId: traceId,
SubjectId: subjectId,
Username: username,
DisplayName: displayName,
ClientId: clientId,
Metadata: metadata);
var entry = await auditService.RecordAsync(record, cancellationToken).ConfigureAwait(false);
var response = MapRecordResponse(entry);
return Results.Created($"/authority/audit/airgap/{entry.Id}", response);
}
private static IReadOnlyDictionary<string, string?>? ValidateMetadata(IReadOnlyDictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string?>(StringComparer.Ordinal);
}
if (metadata.Count > MaxMetadataEntries)
{
return null;
}
var dictionary = new Dictionary<string, string?>(metadata.Count, StringComparer.Ordinal);
foreach (var (key, value) in metadata)
{
var normalizedKey = Normalize(key);
if (normalizedKey is null || normalizedKey.Length > MaxMetadataKeyLength)
{
return null;
}
if (dictionary.ContainsKey(normalizedKey))
{
return null;
}
if (value is { Length: > MaxMetadataValueLength })
{
return null;
}
dictionary[normalizedKey] = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
return dictionary;
}
private static AirgapAuditListItem MapListItem(AirgapAuditEntry entry)
{
return new AirgapAuditListItem(
entry.Id,
entry.Tenant,
entry.SubjectId,
entry.Username,
entry.DisplayName,
entry.ClientId,
entry.BundleId,
entry.Status,
entry.Reason,
entry.TraceId,
entry.OccurredAt,
entry.Metadata);
}
private static AirgapAuditRecordResponse MapRecordResponse(AirgapAuditEntry entry)
{
return new AirgapAuditRecordResponse(
entry.Id,
entry.Tenant,
entry.BundleId,
entry.Status,
entry.Reason,
entry.TraceId,
entry.SubjectId,
entry.Username,
entry.DisplayName,
entry.ClientId,
entry.OccurredAt,
entry.Metadata);
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static string? NormalizeStatus(string? value)
{
var normalized = Normalize(value);
return normalized?.ToLowerInvariant();
}
}
internal sealed record AirgapAuditRecordRequest
{
public required string BundleId { get; init; }
public required string Status { get; init; }
public string? Reason { get; init; }
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
}
internal sealed record AirgapAuditRecordResponse(
string Id,
string Tenant,
string BundleId,
string Status,
string? Reason,
string? TraceId,
string? SubjectId,
string? Username,
string? DisplayName,
string? ClientId,
DateTimeOffset OccurredAt,
IReadOnlyDictionary<string, string?> Metadata);
internal sealed record AirgapAuditListResponse(
IReadOnlyList<AirgapAuditListItem> Items,
string? NextCursor);
internal sealed record AirgapAuditListItem(
string Id,
string Tenant,
string? SubjectId,
string? Username,
string? DisplayName,
string? ClientId,
string BundleId,
string Status,
string? Reason,
string? TraceId,
DateTimeOffset OccurredAt,
IReadOnlyDictionary<string, string?> Metadata);

View File

@@ -0,0 +1,146 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Airgap;
internal interface IAuthorityAirgapAuditService
{
ValueTask<AirgapAuditEntry> RecordAsync(AirgapAuditRecord record, CancellationToken cancellationToken);
ValueTask<AirgapAuditPage> QueryAsync(AirgapAuditSearch search, CancellationToken cancellationToken);
}
internal sealed class AuthorityAirgapAuditService : IAuthorityAirgapAuditService
{
private readonly IAuthorityAirgapAuditStore store;
private readonly TimeProvider timeProvider;
public AuthorityAirgapAuditService(
IAuthorityAirgapAuditStore store,
TimeProvider timeProvider)
{
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async ValueTask<AirgapAuditEntry> RecordAsync(AirgapAuditRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
var document = new AuthorityAirgapAuditDocument
{
Tenant = record.Tenant,
SubjectId = record.SubjectId,
Username = record.Username,
DisplayName = record.DisplayName,
ClientId = record.ClientId,
BundleId = record.BundleId,
Status = record.Status,
Reason = record.Reason,
TraceId = record.TraceId,
OccurredAt = timeProvider.GetUtcNow()
};
if (record.Metadata.Count > 0)
{
var properties = new List<AuthorityAirgapAuditPropertyDocument>(record.Metadata.Count);
foreach (var (name, value) in record.Metadata)
{
properties.Add(new AuthorityAirgapAuditPropertyDocument
{
Name = name,
Value = value
});
}
document.Properties = properties;
}
await store.InsertAsync(document, cancellationToken).ConfigureAwait(false);
return Map(document);
}
public async ValueTask<AirgapAuditPage> QueryAsync(AirgapAuditSearch search, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(search);
var query = new AuthorityAirgapAuditQuery
{
Tenant = search.Tenant,
BundleId = search.BundleId,
Status = search.Status,
TraceId = search.TraceId,
AfterId = search.Cursor,
Limit = search.Limit
};
var result = await store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
var items = result.Items.Select(Map).ToImmutableArray();
return new AirgapAuditPage(items, result.NextCursor);
}
private static AirgapAuditEntry Map(AuthorityAirgapAuditDocument document)
{
IReadOnlyDictionary<string, string?> metadata = document.Properties is { Count: > 0 }
? document.Properties.ToDictionary(
property => property.Name,
property => property.Value,
StringComparer.Ordinal)
: ImmutableDictionary<string, string?>.Empty;
return new AirgapAuditEntry(
document.Id,
document.Tenant,
document.SubjectId,
document.Username,
document.DisplayName,
document.ClientId,
document.BundleId,
document.Status,
document.Reason,
document.TraceId,
document.OccurredAt,
metadata);
}
}
internal sealed record AirgapAuditRecord(
string Tenant,
string BundleId,
string Status,
string? Reason,
string? TraceId,
string? SubjectId,
string? Username,
string? DisplayName,
string? ClientId,
IReadOnlyDictionary<string, string?> Metadata);
internal sealed record AirgapAuditSearch(
string Tenant,
string? BundleId,
string? Status,
string? TraceId,
string? Cursor,
int Limit);
internal sealed record AirgapAuditEntry(
string Id,
string Tenant,
string? SubjectId,
string? Username,
string? DisplayName,
string? ClientId,
string BundleId,
string Status,
string? Reason,
string? TraceId,
DateTimeOffset OccurredAt,
IReadOnlyDictionary<string, string?> Metadata);
internal sealed record AirgapAuditPage(
IReadOnlyList<AirgapAuditEntry> Items,
string? NextCursor);

View File

@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using StellaOps.Configuration;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority;
internal sealed class LegacyAuthDeprecationMiddleware
{
private const string LegacyEventType = "authority.api.legacy_endpoint";
private const string SunsetHeaderName = "Sunset";
private static readonly IReadOnlyDictionary<PathString, PathString> LegacyEndpointMap =
new Dictionary<PathString, PathString>(PathStringComparer.Instance)
{
[new PathString("/oauth/token")] = new PathString("/token"),
[new PathString("/oauth/introspect")] = new PathString("/introspect"),
[new PathString("/oauth/revoke")] = new PathString("/revoke")
};
private readonly RequestDelegate next;
private readonly AuthorityLegacyAuthEndpointOptions options;
private readonly IAuthEventSink auditSink;
private readonly TimeProvider clock;
private readonly ILogger<LegacyAuthDeprecationMiddleware> logger;
public LegacyAuthDeprecationMiddleware(
RequestDelegate next,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
IAuthEventSink auditSink,
TimeProvider clock,
ILogger<LegacyAuthDeprecationMiddleware> logger)
{
this.next = next ?? throw new ArgumentNullException(nameof(next));
if (authorityOptions is null)
{
throw new ArgumentNullException(nameof(authorityOptions));
}
options = authorityOptions.Value.ApiLifecycle.LegacyAuth ??
throw new InvalidOperationException("Authority legacy auth endpoint options are not configured.");
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!options.Enabled)
{
await next(context).ConfigureAwait(false);
return;
}
if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath))
{
await next(context).ConfigureAwait(false);
return;
}
var originalPath = context.Request.Path;
context.Request.Path = canonicalPath;
logger.LogInformation(
"Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.",
originalPath,
canonicalPath);
AppendDeprecationHeaders(context.Response);
await next(context).ConfigureAwait(false);
await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false);
}
private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath)
{
if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath))
{
return true;
}
canonicalPath = PathString.Empty;
return false;
}
private static PathString Normalize(PathString value)
{
if (!value.HasValue)
{
return PathString.Empty;
}
var trimmed = value.Value!.TrimEnd('/');
return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant());
}
private void AppendDeprecationHeaders(HttpResponse response)
{
if (response.HasStarted)
{
return;
}
var deprecation = FormatHttpDate(options.DeprecationDate);
response.Headers["Deprecation"] = deprecation;
var sunset = FormatHttpDate(options.SunsetDate);
response.Headers[SunsetHeaderName] = sunset;
if (!string.IsNullOrWhiteSpace(options.DocumentationUrl))
{
var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\"";
response.Headers.Append(HeaderNames.Link, linkValue);
}
var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\"";
response.Headers[HeaderNames.Warning] = warning;
}
private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath)
{
try
{
var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
var network = BuildNetwork(context);
var record = new AuthEventRecord
{
EventType = LegacyEventType,
OccurredAt = clock.GetUtcNow(),
CorrelationId = correlation,
Outcome = AuthEventOutcome.Success,
Reason = null,
Subject = null,
Client = null,
Tenant = ClassifiedString.Empty,
Project = ClassifiedString.Empty,
Scopes = Array.Empty<string>(),
Network = network,
Properties = BuildProperties(
("legacy.endpoint.original", originalPath.Value),
("legacy.endpoint.canonical", canonicalPath.Value),
("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)),
("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)),
("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture)))
};
await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event.");
}
}
private static AuthEventNetwork? BuildNetwork(HttpContext context)
{
var remote = context.Connection.RemoteIpAddress?.ToString();
var forwarded = context.Request.Headers["X-Forwarded-For"].ToString();
var userAgent = context.Request.Headers.UserAgent.ToString();
if (string.IsNullOrWhiteSpace(remote) &&
string.IsNullOrWhiteSpace(forwarded) &&
string.IsNullOrWhiteSpace(userAgent))
{
return null;
}
return new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal(Normalize(remote)),
ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)),
UserAgent = ClassifiedString.Personal(Normalize(userAgent))
};
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
{
if (entries.Length == 0)
{
return Array.Empty<AuthEventProperty>();
}
var list = new List<AuthEventProperty>(entries.Length);
foreach (var (name, value) in entries)
{
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
list.Add(new AuthEventProperty
{
Name = name,
Value = string.IsNullOrWhiteSpace(value)
? ClassifiedString.Empty
: ClassifiedString.Public(value)
});
}
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
}
private static string FormatHttpDate(DateTimeOffset value)
{
return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
}
private sealed class PathStringComparer : IEqualityComparer<PathString>
{
public static readonly PathStringComparer Instance = new();
public bool Equals(PathString x, PathString y)
{
return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal);
}
public int GetHashCode(PathString obj)
{
return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
}
}
}
internal static class LegacyAuthDeprecationExtensions
{
public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
return app.UseMiddleware<LegacyAuthDeprecationMiddleware>();
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Authority.Notifications.Ack;
internal sealed class AckTokenEnvelope
{
[JsonPropertyName("payloadType")]
public string? PayloadType { get; set; }
[JsonPropertyName("payload")]
public string? Payload { get; set; }
[JsonPropertyName("signatures")]
public List<AckTokenSignature> Signatures { get; set; } = new();
}
internal sealed class AckTokenSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string? Signature { get; set; }
[JsonPropertyName("algorithm")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Algorithm { get; set; }
}
internal sealed class AckTokenIssueRequest
{
public string Tenant { get; set; } = string.Empty;
public string NotificationId { get; set; } = string.Empty;
public string DeliveryId { get; set; } = string.Empty;
public string Channel { get; set; } = string.Empty;
public string WebhookUrl { get; set; } = string.Empty;
public string[]? Actions { get; set; }
public bool AllowEscalation { get; set; }
public TimeSpan? Lifetime { get; set; }
public string? Nonce { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
}
internal sealed class AckTokenVerifyRequest
{
public AckTokenEnvelope? Envelope { get; set; }
public string Action { get; set; } = "ack";
public string? ExpectedTenant { get; set; }
}
internal sealed record AckTokenIssueResult(
AckTokenEnvelope Envelope,
AckTokenPayload Payload,
string KeyId);
internal sealed record AckTokenVerificationResult(
AckTokenPayload Payload,
string KeyId,
bool SignatureValid);
internal sealed record AckTokenIssueResponse(
string PayloadType,
string Payload,
IReadOnlyCollection<AckTokenSignatureResponse> Signatures,
DateTimeOffset IssuedAt,
DateTimeOffset ExpiresAt,
string Nonce);
internal sealed record AckTokenSignatureResponse(
string KeyId,
string Signature,
string Algorithm);
internal sealed record AckTokenVerifyResponse(
string Tenant,
string NotificationId,
string DeliveryId,
string Channel,
IReadOnlyCollection<string> Actions,
bool EscalationAllowed,
DateTimeOffset ExpiresAt,
string Nonce);

View File

@@ -0,0 +1,252 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace StellaOps.Authority.Notifications.Ack;
internal sealed class AckTokenPayload
{
private static readonly JsonWriterOptions WriterOptions = new()
{
Indented = false,
SkipValidation = false
};
public AckTokenPayload(
string version,
string issuer,
string tenant,
string notificationId,
string deliveryId,
string channel,
string webhook,
string nonce,
DateTimeOffset issuedAt,
DateTimeOffset expiresAt,
IReadOnlyList<string> actions,
bool escalationAllowed,
string? escalationScope,
IReadOnlyDictionary<string, string>? metadata)
{
Version = version;
Issuer = issuer;
Tenant = tenant;
NotificationId = notificationId;
DeliveryId = deliveryId;
Channel = channel;
Webhook = webhook;
Nonce = nonce;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
Actions = actions;
EscalationAllowed = escalationAllowed;
EscalationScope = escalationScope;
Metadata = metadata;
}
public string Version { get; }
public string Issuer { get; }
public string Tenant { get; }
public string NotificationId { get; }
public string DeliveryId { get; }
public string Channel { get; }
public string Webhook { get; }
public string Nonce { get; }
public DateTimeOffset IssuedAt { get; }
public DateTimeOffset ExpiresAt { get; }
public IReadOnlyList<string> Actions { get; }
public bool EscalationAllowed { get; }
public string? EscalationScope { get; }
public IReadOnlyDictionary<string, string>? Metadata { get; }
public byte[] ToCanonicalJson()
{
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
WriteCanonicalJson(writer);
}
return buffer.WrittenSpan.ToArray();
}
public void WriteCanonicalJson(Utf8JsonWriter writer)
{
ArgumentNullException.ThrowIfNull(writer);
writer.WriteStartObject();
writer.WriteString("version", Version);
writer.WriteString("issuer", Issuer);
writer.WriteString("tenant", Tenant);
writer.WriteString("notificationId", NotificationId);
writer.WriteString("deliveryId", DeliveryId);
writer.WriteString("channel", Channel);
writer.WriteString("webhook", Webhook);
writer.WriteString("nonce", Nonce);
writer.WriteString("issuedAt", IssuedAt.UtcDateTime.ToString("O"));
writer.WriteString("expiresAt", ExpiresAt.UtcDateTime.ToString("O"));
writer.WritePropertyName("actions");
writer.WriteStartArray();
foreach (var action in Actions.OrderBy(static a => a, StringComparer.Ordinal))
{
writer.WriteStringValue(action);
}
writer.WriteEndArray();
writer.WriteBoolean("escalationAllowed", EscalationAllowed);
if (!string.IsNullOrWhiteSpace(EscalationScope))
{
writer.WriteString("escalationScope", EscalationScope);
}
if (Metadata is { Count: > 0 })
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var entry in Metadata.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
writer.WriteString(entry.Key, entry.Value);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
public static AckTokenPayload Parse(ReadOnlySpan<byte> json)
{
using var document = JsonDocument.Parse(json.ToArray());
var root = document.RootElement;
string RequireString(string property)
{
if (!root.TryGetProperty(property, out var element) || element.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException($"Ack token payload is missing required property '{property}'.");
}
var value = element.GetString();
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Ack token payload property '{property}' cannot be empty.");
}
return value!;
}
var version = RequireString("version");
var issuer = RequireString("issuer");
var tenant = RequireString("tenant");
var notificationId = RequireString("notificationId");
var deliveryId = RequireString("deliveryId");
var channel = RequireString("channel");
var webhook = RequireString("webhook");
var nonce = RequireString("nonce");
var issuedAt = ParseTimestamp(root, "issuedAt");
var expiresAt = ParseTimestamp(root, "expiresAt");
if (!root.TryGetProperty("actions", out var actionsElement) || actionsElement.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Ack token payload must contain an 'actions' array.");
}
var actions = new List<string>();
foreach (var item in actionsElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException("Ack token payload actions must be strings.");
}
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
actions.Add(value.Trim().ToLowerInvariant());
}
}
if (actions.Count == 0)
{
throw new InvalidOperationException("Ack token payload must contain at least one action.");
}
var escalationAllowed = root.TryGetProperty("escalationAllowed", out var flagElement) &&
flagElement.ValueKind == JsonValueKind.True;
string? escalationScope = null;
if (root.TryGetProperty("escalationScope", out var scopeElement) && scopeElement.ValueKind == JsonValueKind.String)
{
var value = scopeElement.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
escalationScope = value.Trim();
}
}
IReadOnlyDictionary<string, string>? metadata = null;
if (root.TryGetProperty("metadata", out var metadataElement) && metadataElement.ValueKind == JsonValueKind.Object)
{
var dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var property in metadataElement.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String)
{
dictionary[property.Name] = property.Value.GetString() ?? string.Empty;
}
}
if (dictionary.Count > 0)
{
metadata = dictionary;
}
}
return new AckTokenPayload(
version,
issuer,
tenant,
notificationId,
deliveryId,
channel,
webhook,
nonce,
issuedAt,
expiresAt,
actions,
escalationAllowed,
escalationScope,
metadata);
}
private static DateTimeOffset ParseTimestamp(JsonElement root, string property)
{
var value = root.TryGetProperty(property, out var element) && element.ValueKind == JsonValueKind.String
? element.GetString()
: null;
if (string.IsNullOrWhiteSpace(value) || !DateTimeOffset.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var timestamp))
{
throw new InvalidOperationException($"Ack token payload property '{property}' is missing or invalid.");
}
return timestamp;
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Text;
namespace StellaOps.Authority.Notifications.Ack;
internal static class AckTokenSigningUtilities
{
private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("DSSEv1");
public static byte[] CreatePreAuthenticationEncoding(string payloadType, ReadOnlySpan<byte> payload)
{
ArgumentException.ThrowIfNullOrEmpty(payloadType);
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var totalLength = Prefix.Length +
sizeof(long) + // number of fields
sizeof(long) + payloadTypeBytes.Length +
sizeof(long) + payload.Length;
using var stream = new MemoryStream(totalLength);
stream.Write(Prefix);
WriteInt64(stream, 2);
WriteField(stream, payloadTypeBytes);
WriteField(stream, payload);
return stream.ToArray();
}
private static void WriteField(Stream stream, ReadOnlySpan<byte> value)
{
WriteInt64(stream, value.Length);
stream.Write(value);
}
private static void WriteInt64(Stream stream, long value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(buffer, value);
stream.Write(buffer);
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Configuration;
namespace StellaOps.Authority.Notifications.Ack;
internal sealed class AuthorityAckTokenIssuer
{
private readonly AuthorityAckTokenKeyManager keyManager;
private readonly AuthorityWebhookAllowlistEvaluator allowlistEvaluator;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly TimeProvider timeProvider;
private readonly ILogger<AuthorityAckTokenIssuer> logger;
public AuthorityAckTokenIssuer(
AuthorityAckTokenKeyManager keyManager,
AuthorityWebhookAllowlistEvaluator allowlistEvaluator,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
TimeProvider timeProvider,
ILogger<AuthorityAckTokenIssuer> logger)
{
this.keyManager = keyManager ?? throw new ArgumentNullException(nameof(keyManager));
this.allowlistEvaluator = allowlistEvaluator ?? throw new ArgumentNullException(nameof(allowlistEvaluator));
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AckTokenIssueResult> IssueAsync(
AckTokenIssueRequest request,
bool requesterHasEscalateScope,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var ackOptions = authorityOptions.Notifications.AckTokens;
if (!ackOptions.Enabled)
{
throw new InvalidOperationException("Ack tokens are disabled. Enable notifications.ackTokens to issue tokens.");
}
var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
var tenant = Require(request.Tenant, nameof(request.Tenant));
var notificationId = Require(request.NotificationId, nameof(request.NotificationId));
var deliveryId = Require(request.DeliveryId, nameof(request.DeliveryId));
var channel = Require(request.Channel, nameof(request.Channel));
var webhookUrl = Require(request.WebhookUrl, nameof(request.WebhookUrl));
var normalizedNonce = string.IsNullOrWhiteSpace(request.Nonce)
? Guid.NewGuid().ToString("N")
: request.Nonce!.Trim();
if (!Uri.TryCreate(webhookUrl, UriKind.Absolute, out var webhookUri))
{
throw new InvalidOperationException("Webhook URL must be an absolute URI.");
}
allowlistEvaluator.EnsureAllowed(webhookUri);
var lifetime = request.Lifetime ?? ackOptions.DefaultLifetime;
if (lifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Requested lifetime must be greater than zero.");
}
if (lifetime > ackOptions.MaxLifetime)
{
throw new InvalidOperationException($"Requested lifetime exceeds the configured maximum ({ackOptions.MaxLifetime}).");
}
var normalizedActions = NormalizeActions(request.Actions);
if (normalizedActions.Count == 0)
{
normalizedActions.Add("ack");
}
if (!normalizedActions.Contains("ack", StringComparer.Ordinal))
{
normalizedActions.Insert(0, "ack");
}
var escalationOptions = authorityOptions.Notifications.Escalation;
var escalationAllowed = request.AllowEscalation;
if (escalationAllowed)
{
if (string.IsNullOrWhiteSpace(escalationOptions.Scope))
{
throw new InvalidOperationException("Escalation scope configuration is missing.");
}
if (!normalizedActions.Contains("escalate", StringComparer.Ordinal))
{
normalizedActions.Add("escalate");
}
if (escalationOptions.RequireAdminScope && !requesterHasEscalateScope)
{
throw new InvalidOperationException("Escalation is not permitted without the notify.escalate scope.");
}
}
IReadOnlyDictionary<string, string>? metadata = null;
if (request.Metadata is { Count: > 0 })
{
metadata = request.Metadata
.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key))
.ToDictionary(
static pair => pair.Key.Trim(),
static pair => pair.Value ?? string.Empty,
StringComparer.Ordinal);
if (metadata.Count == 0)
{
metadata = null;
}
}
var issuedAt = timeProvider.GetUtcNow();
var expiresAt = issuedAt.Add(lifetime);
var payload = new AckTokenPayload(
version: "1.0",
issuer: issuer.ToString(),
tenant: tenant,
notificationId: notificationId,
deliveryId: deliveryId,
channel: channel,
webhook: webhookUri.ToString(),
nonce: normalizedNonce,
issuedAt: issuedAt,
expiresAt: expiresAt,
actions: normalizedActions,
escalationAllowed: escalationAllowed,
escalationScope: escalationAllowed ? escalationOptions.Scope : null,
metadata: metadata);
var canonicalPayload = payload.ToCanonicalJson();
var pae = AckTokenSigningUtilities.CreatePreAuthenticationEncoding(ackOptions.PayloadType, canonicalPayload);
var signer = keyManager.GetActiveSigner();
var signature = await signer.Signer.SignAsync(pae, cancellationToken).ConfigureAwait(false);
var envelope = new AckTokenEnvelope
{
PayloadType = ackOptions.PayloadType,
Payload = Base64UrlEncoder.Encode(canonicalPayload),
Signatures =
{
new AckTokenSignature
{
KeyId = signer.Signer.KeyId,
Signature = Base64UrlEncoder.Encode(signature),
Algorithm = signer.Signer.AlgorithmId
}
}
};
logger.LogInformation("Issued ack token for notification {NotificationId} (tenant {Tenant}).", notificationId, tenant);
return new AckTokenIssueResult(envelope, payload, signer.Signer.KeyId);
}
private static string Require(string value, string propertyName)
{
ArgumentNullException.ThrowIfNull(value, propertyName);
var trimmed = value.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
throw new InvalidOperationException($"Property '{propertyName}' is required.");
}
return trimmed;
}
private static List<string> NormalizeActions(IEnumerable<string>? actions)
{
if (actions is null)
{
return new List<string>();
}
var distinct = new HashSet<string>(StringComparer.Ordinal);
foreach (var action in actions)
{
if (string.IsNullOrWhiteSpace(action))
{
continue;
}
var normalized = action.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
continue;
}
distinct.Add(normalized);
}
return distinct.OrderBy(static value => value, StringComparer.Ordinal).ToList();
}
}

View File

@@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Notifications.Ack;
internal sealed class AuthorityAckTokenKeyManager
{
private readonly object syncRoot = new();
private readonly ICryptoProviderRegistry registry;
private readonly IReadOnlyList<IAuthoritySigningKeySource> keySources;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly string basePath;
private readonly ILogger<AuthorityAckTokenKeyManager> logger;
private readonly AuthorityJwksService jwksService;
private readonly Dictionary<string, RegisteredAckKey> retiredKeys = new(StringComparer.OrdinalIgnoreCase);
private RegisteredAckKey? activeKey;
public AuthorityAckTokenKeyManager(
ICryptoProviderRegistry registry,
IEnumerable<IAuthoritySigningKeySource> keySources,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
IHostEnvironment environment,
ILogger<AuthorityAckTokenKeyManager> logger,
AuthorityJwksService jwksService)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.keySources = (keySources ?? throw new ArgumentNullException(nameof(keySources))).ToArray();
if (this.keySources.Count == 0)
{
throw new InvalidOperationException("At least one Authority signing key source must be registered.");
}
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
basePath = environment?.ContentRootPath ?? throw new ArgumentNullException(nameof(environment));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.jwksService = jwksService ?? throw new ArgumentNullException(nameof(jwksService));
LoadInitialKeys();
}
public SigningRotationResult Rotate(SigningRotationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
lock (syncRoot)
{
var ackOptions = authorityOptions.Notifications.AckTokens;
if (!ackOptions.Enabled)
{
throw new InvalidOperationException("Ack token signing is disabled. Enable notifications.ackTokens before rotating keys.");
}
var keyId = NormaliseKeyId(request.KeyId);
var location = NormaliseLocation(request.Location);
var algorithm = NormaliseAlgorithm(string.IsNullOrWhiteSpace(request.Algorithm) ? ackOptions.Algorithm : request.Algorithm);
var source = NormaliseSource(string.IsNullOrWhiteSpace(request.Source) ? ackOptions.KeySource : request.Source);
var providerName = NormaliseProvider(request.Provider ?? ackOptions.Provider);
var metadata = BuildMetadata(AuthoritySigningKeyStatus.Active, ackOptions.KeyUse, request.Metadata);
var provider = ResolveProvider(providerName, algorithm);
var loader = ResolveSource(source);
var loadRequest = new AuthoritySigningKeyRequest(
keyId,
algorithm,
source,
location,
AuthoritySigningKeyStatus.Active,
basePath,
provider.Name,
additionalMetadata: metadata);
var newKey = loader.Load(loadRequest);
provider.UpsertSigningKey(newKey);
if (retiredKeys.Remove(keyId))
{
logger.LogInformation("Promoted retired ack token key {KeyId} to active status.", keyId);
}
string? previousKeyId = null;
if (activeKey is not null)
{
previousKeyId = activeKey.Key.Reference.KeyId;
if (!string.Equals(previousKeyId, keyId, StringComparison.OrdinalIgnoreCase))
{
RetireCurrentActive();
}
}
activeKey = new RegisteredAckKey(newKey, provider.Name, source, location);
ackOptions.ActiveKeyId = keyId;
ackOptions.KeyPath = location;
ackOptions.KeySource = source;
ackOptions.Provider = provider.Name;
RemoveAdditionalOption(keyId);
logger.LogInformation("Ack token signing key rotated. Active key is now {KeyId} via provider {Provider}.", keyId, provider.Name);
jwksService.Invalidate();
return new SigningRotationResult(
keyId,
provider.Name,
source,
location,
previousKeyId,
retiredKeys.Keys.ToArray());
}
}
public CryptoSignerResolution GetActiveSigner()
{
lock (syncRoot)
{
if (activeKey is null)
{
throw new InvalidOperationException("Ack token signing is not configured.");
}
return ResolveSigner(activeKey);
}
}
public bool TryResolveSigner(string keyId, out CryptoSignerResolution resolution)
{
ArgumentException.ThrowIfNullOrEmpty(keyId);
lock (syncRoot)
{
if (activeKey is not null && string.Equals(activeKey.Key.Reference.KeyId, keyId, StringComparison.OrdinalIgnoreCase))
{
resolution = ResolveSigner(activeKey);
return true;
}
if (retiredKeys.TryGetValue(keyId, out var retired))
{
resolution = ResolveSigner(retired);
return true;
}
}
resolution = default!;
return false;
}
private void LoadInitialKeys()
{
var ackOptions = authorityOptions.Notifications.AckTokens;
if (!ackOptions.Enabled)
{
logger.LogInformation("Ack token signing is disabled; issuance and verification endpoints will reject requests.");
return;
}
var algorithm = NormaliseAlgorithm(ackOptions.Algorithm);
var source = NormaliseSource(ackOptions.KeySource);
var metadata = BuildMetadata(AuthoritySigningKeyStatus.Active, ackOptions.KeyUse, null);
var activeRequest = new AuthoritySigningKeyRequest(
NormaliseKeyId(ackOptions.ActiveKeyId),
algorithm,
source,
NormaliseLocation(ackOptions.KeyPath),
AuthoritySigningKeyStatus.Active,
basePath,
NormaliseProvider(ackOptions.Provider),
additionalMetadata: metadata);
activeKey = LoadAndRegister(activeRequest);
ackOptions.KeySource = source;
ackOptions.Provider = activeKey.ProviderName;
foreach (var additional in ackOptions.AdditionalKeys)
{
var keyId = (additional.KeyId ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(keyId))
{
logger.LogWarning("Skipped additional ack token key with empty keyId.");
continue;
}
if (activeKey.Key.Reference.KeyId.Equals(keyId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var additionalLocation = additional.Path?.Trim();
if (string.IsNullOrWhiteSpace(additionalLocation))
{
logger.LogWarning("Additional ack token key {KeyId} is missing a path. Skipping.", keyId);
continue;
}
var additionalSource = NormaliseSource(additional.Source ?? source);
var request = new AuthoritySigningKeyRequest(
keyId,
algorithm,
additionalSource,
additionalLocation,
AuthoritySigningKeyStatus.Retired,
basePath,
NormaliseProvider(ackOptions.Provider),
additionalMetadata: BuildMetadata(AuthoritySigningKeyStatus.Retired, ackOptions.KeyUse, null));
try
{
var registration = LoadAndRegister(request);
retiredKeys[registration.Key.Reference.KeyId] = registration;
additional.Source = additionalSource;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load retired ack token key {KeyId}. It will be ignored for verification.", keyId);
}
}
jwksService.Invalidate();
}
private RegisteredAckKey LoadAndRegister(AuthoritySigningKeyRequest request)
{
var loader = ResolveSource(request.Source);
var provider = ResolveProvider(request.Provider, request.Algorithm);
var key = loader.Load(request);
provider.UpsertSigningKey(key);
logger.LogDebug("Loaded ack token key {KeyId} (status {Status}) via provider {Provider}.", key.Reference.KeyId, request.Status, provider.Name);
return new RegisteredAckKey(key, provider.Name, request.Source, request.Location);
}
private void RetireCurrentActive()
{
if (activeKey is null)
{
return;
}
var ackOptions = authorityOptions.Notifications.AckTokens;
var previous = activeKey;
var metadata = BuildMetadata(AuthoritySigningKeyStatus.Retired, ackOptions.KeyUse, previous.Key.Metadata);
var privateParameters = previous.Key.PrivateParameters;
var retiredKey = new CryptoSigningKey(
previous.Key.Reference,
previous.Key.AlgorithmId,
in privateParameters,
previous.Key.CreatedAt,
previous.Key.ExpiresAt,
metadata);
var provider = ResolveProvider(previous.ProviderName, retiredKey.AlgorithmId);
provider.UpsertSigningKey(retiredKey);
var registration = new RegisteredAckKey(retiredKey, provider.Name, previous.Source, previous.Location);
retiredKeys[registration.Key.Reference.KeyId] = registration;
UpsertAdditionalOption(registration);
logger.LogInformation("Moved ack token key {KeyId} to retired set (provider {Provider}).", registration.Key.Reference.KeyId, provider.Name);
}
private void RemoveAdditionalOption(string keyId)
{
var additional = authorityOptions.Notifications.AckTokens.AdditionalKeys;
for (var index = additional.Count - 1; index >= 0; index--)
{
if (string.Equals(additional[index].KeyId, keyId, StringComparison.OrdinalIgnoreCase))
{
additional.RemoveAt(index);
}
}
}
private void UpsertAdditionalOption(RegisteredAckKey registration)
{
var additional = authorityOptions.Notifications.AckTokens.AdditionalKeys;
var existing = additional.FirstOrDefault(key =>
string.Equals(key.KeyId, registration.Key.Reference.KeyId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
additional.Remove(existing);
}
additional.Add(new AuthoritySigningAdditionalKeyOptions
{
KeyId = registration.Key.Reference.KeyId,
Path = registration.Location,
Source = registration.Source
});
}
private CryptoSignerResolution ResolveSigner(RegisteredAckKey key)
{
var resolution = registry.ResolveSigner(
CryptoCapability.Signing,
key.Key.AlgorithmId,
key.Key.Reference,
key.ProviderName);
return resolution;
}
private IAuthoritySigningKeySource ResolveSource(string source)
{
foreach (var loader in keySources)
{
if (loader.CanLoad(source))
{
return loader;
}
}
throw new InvalidOperationException($"Unknown ack token key source '{source}'.");
}
private ICryptoProvider ResolveProvider(string? providerHint, string algorithmId)
{
if (!string.IsNullOrWhiteSpace(providerHint) && registry.TryResolve(providerHint, out var provider))
{
if (!provider.Supports(CryptoCapability.Signing, algorithmId))
{
throw new InvalidOperationException($"Crypto provider '{provider.Name}' does not support algorithm '{algorithmId}'.");
}
return provider;
}
return registry.ResolveOrThrow(CryptoCapability.Signing, algorithmId);
}
private static string NormaliseKeyId(string? value)
{
var keyId = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(keyId))
{
throw new InvalidOperationException("Ack token key rotation requires a keyId.");
}
return keyId;
}
private static string NormaliseLocation(string? path)
{
var location = (path ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(location))
{
throw new InvalidOperationException("Ack token key rotation requires a key path/location.");
}
return location;
}
private static string NormaliseAlgorithm(string? algorithm)
{
return string.IsNullOrWhiteSpace(algorithm)
? SignatureAlgorithms.Es256
: algorithm.Trim();
}
private static string NormaliseSource(string? source)
{
return string.IsNullOrWhiteSpace(source) ? "file" : source.Trim();
}
private static string? NormaliseProvider(string? provider)
{
return string.IsNullOrWhiteSpace(provider) ? null : provider.Trim();
}
private static IReadOnlyDictionary<string, string?> BuildMetadata(
string status,
string use,
IReadOnlyDictionary<string, string?>? existing)
{
var metadata = existing is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(existing, StringComparer.OrdinalIgnoreCase);
metadata["status"] = status;
metadata["use"] = use;
return metadata;
}
private sealed record RegisteredAckKey(
CryptoSigningKey Key,
string ProviderName,
string Source,
string Location);
}

View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Configuration;
namespace StellaOps.Authority.Notifications.Ack;
internal sealed class AuthorityAckTokenVerifier
{
private readonly AuthorityAckTokenKeyManager keyManager;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly TimeProvider timeProvider;
public AuthorityAckTokenVerifier(
AuthorityAckTokenKeyManager keyManager,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
TimeProvider timeProvider)
{
this.keyManager = keyManager ?? throw new ArgumentNullException(nameof(keyManager));
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<AckTokenVerificationResult> VerifyAsync(
AckTokenEnvelope envelope,
string expectedAction,
string? expectedTenant,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentException.ThrowIfNullOrEmpty(expectedAction);
var ackOptions = authorityOptions.Notifications.AckTokens;
if (!ackOptions.Enabled)
{
throw new InvalidOperationException("Ack tokens are disabled.");
}
if (string.IsNullOrWhiteSpace(envelope.PayloadType))
{
throw new InvalidOperationException("Ack token envelope is missing payloadType.");
}
if (!string.Equals(envelope.PayloadType, ackOptions.PayloadType, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Unexpected payloadType '{envelope.PayloadType}'. Expected '{ackOptions.PayloadType}'.");
}
if (string.IsNullOrWhiteSpace(envelope.Payload))
{
throw new InvalidOperationException("Ack token envelope is missing payload.");
}
if (envelope.Signatures.Count == 0)
{
throw new InvalidOperationException("Ack token envelope must include at least one signature.");
}
byte[] payloadBytes;
try
{
payloadBytes = Base64UrlEncoder.DecodeBytes(envelope.Payload);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Ack token payload is not valid base64url.", ex);
}
var payload = AckTokenPayload.Parse(payloadBytes);
if (authorityOptions.Issuer is not null &&
!string.Equals(payload.Issuer, authorityOptions.Issuer.ToString(), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Ack token issuer does not match Authority configuration.");
}
if (!string.IsNullOrWhiteSpace(expectedTenant) &&
!string.Equals(payload.Tenant, expectedTenant, StringComparison.Ordinal))
{
throw new InvalidOperationException("Ack token tenant mismatch.");
}
var pae = AckTokenSigningUtilities.CreatePreAuthenticationEncoding(envelope.PayloadType, payloadBytes);
var verifiedKeyId = await VerifySignaturesAsync(envelope.Signatures, pae, cancellationToken).ConfigureAwait(false);
if (verifiedKeyId is null)
{
throw new InvalidOperationException("Ack token signature validation failed.");
}
if (payload.ExpiresAt <= timeProvider.GetUtcNow())
{
throw new InvalidOperationException("Ack token has expired.");
}
var normalizedAction = expectedAction.Trim().ToLowerInvariant();
if (!payload.Actions.Contains(normalizedAction, StringComparer.Ordinal))
{
throw new InvalidOperationException($"Ack token does not permit action '{normalizedAction}'.");
}
return new AckTokenVerificationResult(payload, verifiedKeyId, true);
}
private async Task<string?> VerifySignaturesAsync(
IReadOnlyCollection<AckTokenSignature> signatures,
ReadOnlyMemory<byte> pae,
CancellationToken cancellationToken)
{
foreach (var signature in signatures)
{
if (signature.KeyId is null || signature.Signature is null)
{
continue;
}
if (!keyManager.TryResolveSigner(signature.KeyId, out var signer))
{
continue;
}
byte[] signatureBytes;
try
{
signatureBytes = Base64UrlEncoder.DecodeBytes(signature.Signature);
}
catch (FormatException)
{
continue;
}
if (await signer.Signer.VerifyAsync(pae, signatureBytes, cancellationToken).ConfigureAwait(false))
{
return signer.Signer.KeyId;
}
}
return null;
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Configuration;
namespace StellaOps.Authority.Notifications;
internal sealed class AuthorityWebhookAllowlistEvaluator
{
private readonly AuthorityWebhookAllowlistOptions options;
private readonly IReadOnlyList<string> allowedHosts;
private readonly HashSet<string> allowedSchemes;
public AuthorityWebhookAllowlistEvaluator(IOptions<StellaOpsAuthorityOptions> authorityOptions)
{
ArgumentNullException.ThrowIfNull(authorityOptions);
var notifications = authorityOptions.Value.Notifications ?? throw new InvalidOperationException("Authority notifications configuration is missing.");
options = notifications.Webhooks ?? throw new InvalidOperationException("Authority webhook configuration is missing.");
allowedHosts = options.AllowedHosts
.Select(static host => host.Trim())
.Where(static host => host.Length > 0)
.ToArray();
allowedSchemes = options.AllowedSchemes
.Select(static scheme => scheme.Trim().ToLowerInvariant())
.Where(static scheme => scheme.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
public void EnsureAllowed(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (!options.Enabled)
{
return;
}
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException("Webhook URL must be an absolute URI.");
}
if (allowedSchemes.Count > 0 && !allowedSchemes.Contains(uri.Scheme))
{
throw new InvalidOperationException($"Webhook scheme '{uri.Scheme}' is not permitted. Allowed schemes: {string.Join(", ", allowedSchemes)}.");
}
if (allowedHosts.Count == 0)
{
throw new InvalidOperationException("Webhook allowlist is empty; configure notifications.webhooks.allowedHosts or disable allowlist enforcement.");
}
var host = uri.Host;
var port = uri.IsDefaultPort ? (int?)null : uri.Port;
foreach (var entry in allowedHosts)
{
if (Matches(entry, host, port))
{
return;
}
}
throw new InvalidOperationException($"Webhook host '{host}' is not present in the allowlist. Configure notifications.webhooks.allowedHosts to permit it.");
}
private static bool Matches(string pattern, string host, int? port)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
var trimmed = pattern.Trim();
string? patternHost = trimmed;
int? patternPort = null;
var colonIndex = trimmed.LastIndexOf(':');
if (colonIndex > 0 && colonIndex < trimmed.Length - 1 && trimmed.IndexOf(']') == -1)
{
var potentialPort = trimmed[(colonIndex + 1)..];
if (int.TryParse(potentialPort, out var parsedPort) && parsedPort > 0)
{
patternPort = parsedPort;
patternHost = trimmed[..colonIndex];
}
}
if (patternPort.HasValue && port.HasValue && patternPort.Value != port.Value)
{
return false;
}
if (patternHost.StartsWith("*.", StringComparison.Ordinal))
{
var suffix = patternHost[1..];
return host.Length > suffix.Length &&
host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(patternHost, host, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Observability;
internal static class IncidentAuditEndpointExtensions
{
private const int DefaultPageSize = 50;
private const int MaxPageSize = 200;
public static void MapIncidentAuditEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/authority/audit/incident")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ObservabilityRead))
.WithTags("AuthorityIncidentAudit");
group.AddEndpointFilter(new TenantHeaderFilter());
group.MapGet("/", GetIncidentAuditAsync)
.WithName("GetIncidentAudit")
.WithSummary("List recent obs:incident token issuances for auditors.")
.Produces<IncidentAuditResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
}
private static async Task<IResult> GetIncidentAuditAsync(
HttpContext httpContext,
IAuthorityTokenStore tokenStore,
[FromQuery(Name = "since")] DateTimeOffset? since,
[FromQuery(Name = "limit")] int? limit,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(tokenStore);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var effectiveLimit = limit.HasValue ? Math.Clamp(limit.Value, 1, MaxPageSize) : DefaultPageSize;
IReadOnlyList<AuthorityTokenDocument> documents;
try
{
documents = await tokenStore.ListByScopeAsync(
StellaOpsScopes.ObservabilityIncident,
tenant,
since,
effectiveLimit,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "incident_audit_query_failed",
detail: ex.Message);
}
var items = documents
.Select(doc => new IncidentAuditEntry(
doc.TokenId,
doc.ClientId,
doc.SubjectId,
doc.Tenant,
doc.IncidentReason,
doc.CreatedAt,
doc.ExpiresAt))
.ToArray();
return Results.Ok(new IncidentAuditResponse(items));
}
}
internal sealed record IncidentAuditResponse(IncidentAuditEntry[] Items);
internal sealed record IncidentAuditEntry(
string TokenId,
string? ClientId,
string? SubjectId,
string? Tenant,
string? Reason,
DateTimeOffset IssuedAt,
DateTimeOffset? ExpiresAt);

View File

@@ -103,7 +103,7 @@ internal sealed class AuthorityOpenApiDocumentProvider
rootNode.Children[new YamlScalarNode("info")] = infoNode;
}
var serviceName = "StellaOps.Authority";
var serviceName = "authority";
var buildVersion = ResolveBuildVersion();
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
@@ -20,13 +21,14 @@ internal static class OpenApiDiscoveryEndpointExtensions
{
ArgumentNullException.ThrowIfNull(endpoints);
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, [FromServices] AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
{
var snapshot = await provider.GetDocumentAsync(cancellationToken).ConfigureAwait(false);
var preferYaml = ShouldReturnYaml(context.Request.GetTypedHeaders().Accept);
var payload = preferYaml ? snapshot.Yaml : snapshot.Json;
var mediaType = preferYaml ? YamlMediaType : JsonMediaType;
var contentType = string.Create(CultureInfo.InvariantCulture, $"{mediaType}; charset=utf-8");
ApplyMetadataHeaders(context.Response, snapshot);
@@ -37,7 +39,7 @@ internal static class OpenApiDiscoveryEndpointExtensions
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = mediaType;
context.Response.ContentType = contentType;
await context.Response.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
});

View File

@@ -15,6 +15,7 @@ internal static class AuthorityOpenIddictConstants
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
internal const string AuditSuccessRecordedProperty = "authority:audit_success_recorded";
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
internal const string SenderConstraintProperty = "authority:sender_constraint";
internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint";
@@ -35,4 +36,10 @@ internal static class AuthorityOpenIddictConstants
internal const string ExportAdminTicketProperty = "authority:export_admin_ticket";
internal const string ExportAdminReasonParameterName = "export_reason";
internal const string ExportAdminTicketParameterName = "export_ticket";
internal const string IncidentReasonProperty = "authority:incident_reason";
internal const string IncidentReasonParameterName = "incident_reason";
internal const string QuotaReasonProperty = "authority:quota_reason";
internal const string QuotaTicketProperty = "authority:quota_ticket";
internal const string QuotaReasonParameterName = "quota_reason";
internal const string QuotaTicketParameterName = "quota_ticket";
}

View File

@@ -288,18 +288,27 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate;
var hasOrchRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchRead) >= 0;
var hasOrchOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchOperate) >= 0;
var hasOrchQuota = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchQuota) >= 0;
var hasExportViewer = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportViewer) >= 0;
var hasExportOperator = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportOperator) >= 0;
var hasExportAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportAdmin) >= 0;
var exportScopesRequested = hasExportViewer || hasExportOperator || hasExportAdmin;
var hasAdvisoryIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryIngest) >= 0;
var hasAdvisoryRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryRead) >= 0;
var hasAdvisoryAiView = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryAiView) >= 0;
var hasAdvisoryAiOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryAiOperate) >= 0;
var hasAdvisoryAiAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryAiAdmin) >= 0;
var advisoryAiScopesRequested = hasAdvisoryAiView || hasAdvisoryAiOperate || hasAdvisoryAiAdmin;
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
var hasObservabilityIncident = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ObservabilityIncident) >= 0;
var hasSignalsRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsRead) >= 0;
var hasSignalsWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsWrite) >= 0;
var hasSignalsAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsAdmin) >= 0;
var hasAirgapSeal = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AirgapSeal) >= 0;
var hasAirgapImport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AirgapImport) >= 0;
var hasAirgapStatusRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AirgapStatusRead) >= 0;
var signalsScopesRequested = hasSignalsRead || hasSignalsWrite || hasSignalsAdmin;
var hasPolicyAuthor = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAuthor) >= 0;
var hasPolicyReview = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyReview) >= 0;
@@ -309,6 +318,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var hasPolicyRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRun) >= 0;
var hasPolicyActivate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyActivate) >= 0;
var hasPolicySimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicySimulate) >= 0;
var hasPacksRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PacksRead) >= 0;
var hasPacksWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PacksWrite) >= 0;
var hasPacksRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PacksRun) >= 0;
var hasPacksApprove = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PacksApprove) >= 0;
var packsScopesRequested = hasPacksRead || hasPacksWrite || hasPacksRun || hasPacksApprove;
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
|| hasPolicyOperate
@@ -320,6 +334,34 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|| grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRead) >= 0;
var hasAocVerify = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AocVerify) >= 0;
if (hasObservabilityIncident)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.ObservabilityIncident;
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'obs:incident' requires interactive authentication and cannot be issued via client credentials.");
activity?.SetTag("authority.incident_scope_rejected", true);
logger.LogWarning("Client credentials validation failed for {ClientId}: obs:incident requires interactive authentication.", document.ClientId);
return;
}
if (packsScopesRequested && !EnsureTenantAssigned())
{
var packsScopeForAudit = hasPacksWrite
? StellaOpsScopes.PacksWrite
: hasPacksRun
? StellaOpsScopes.PacksRun
: hasPacksApprove
? StellaOpsScopes.PacksApprove
: StellaOpsScopes.PacksRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = packsScopeForAudit;
activity?.SetTag("authority.pack_scope_violation", packsScopeForAudit);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Pack scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: pack scopes require tenant assignment.",
document.ClientId);
return;
}
if (exportScopesRequested && !EnsureTenantAssigned())
{
var exportScopeForAudit = hasExportAdmin
@@ -354,20 +396,54 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasAdvisoryIngest || hasAdvisoryRead) && !EnsureTenantAssigned())
if ((hasAdvisoryIngest || hasAdvisoryRead || advisoryAiScopesRequested) && !EnsureTenantAssigned())
{
var advisoryScope = hasAdvisoryIngest ? StellaOpsScopes.AdvisoryIngest : StellaOpsScopes.AdvisoryRead;
var advisoryScope = hasAdvisoryIngest
? StellaOpsScopes.AdvisoryIngest
: hasAdvisoryRead
? StellaOpsScopes.AdvisoryRead
: hasAdvisoryAiAdmin
? StellaOpsScopes.AdvisoryAiAdmin
: hasAdvisoryAiOperate
? StellaOpsScopes.AdvisoryAiOperate
: StellaOpsScopes.AdvisoryAiView;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = advisoryScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Advisory scopes require a tenant assignment.");
var errorMessage = advisoryAiScopesRequested
? "Advisory AI scopes require a tenant assignment."
: "Advisory scopes require a tenant assignment.";
context.Reject(OpenIddictConstants.Errors.InvalidClient, errorMessage);
logger.LogWarning(
"Client credentials validation failed for {ClientId}: advisory scopes require tenant assignment.",
"Client credentials validation failed for {ClientId}: {ScopeType} scopes require tenant assignment.",
document.ClientId,
advisoryAiScopesRequested ? "advisory AI" : "advisory");
return;
}
if ((hasAirgapSeal || hasAirgapImport || hasAirgapStatusRead) && !EnsureTenantAssigned())
{
var invalidScope = hasAirgapSeal
? StellaOpsScopes.AirgapSeal
: hasAirgapImport
? StellaOpsScopes.AirgapImport
: StellaOpsScopes.AirgapStatusRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
activity?.SetTag("authority.airgap_scope_violation", "tenant_required");
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Air-gap scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: air-gap scopes require tenant assignment.",
document.ClientId);
return;
}
if ((hasOrchRead || hasOrchOperate) && !EnsureTenantAssigned())
if ((hasOrchRead || hasOrchOperate || hasOrchQuota) && !EnsureTenantAssigned())
{
var invalidScope = hasOrchOperate ? StellaOpsScopes.OrchOperate : StellaOpsScopes.OrchRead;
var invalidScope = hasOrchQuota
? StellaOpsScopes.OrchQuota
: hasOrchOperate
? StellaOpsScopes.OrchOperate
: StellaOpsScopes.OrchRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
logger.LogWarning(
@@ -416,6 +492,43 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
activity?.SetTag("authority.operator_ticket_present", true);
}
if (hasOrchQuota)
{
var quotaReasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName)?.Value?.ToString();
var quotaReason = NormalizeMetadata(quotaReasonRaw);
if (string.IsNullOrWhiteSpace(quotaReason))
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Quota changes require 'quota_reason'.");
logger.LogWarning("Client credentials validation failed for {ClientId}: quota_reason missing.", document.ClientId);
return;
}
if (quotaReason.Length > 256)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Quota reason must not exceed 256 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: quota_reason exceeded length limit.", document.ClientId);
return;
}
var quotaTicketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.QuotaTicketParameterName)?.Value?.ToString();
var quotaTicket = NormalizeMetadata(quotaTicketRaw);
if (!string.IsNullOrWhiteSpace(quotaTicket) && quotaTicket.Length > 128)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Quota ticket must not exceed 128 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: quota_ticket exceeded length limit.", document.ClientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty] = quotaReason;
activity?.SetTag("authority.quota_reason_present", true);
if (!string.IsNullOrWhiteSpace(quotaTicket))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaTicketProperty] = quotaTicket;
activity?.SetTag("authority.quota_ticket_present", true);
}
}
if (hasExportAdmin)
{
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName)?.Value?.ToString();
@@ -477,13 +590,20 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasAdvisoryRead || hasVexRead) && !hasAocVerify)
if ((hasAdvisoryRead || hasVexRead || advisoryAiScopesRequested) && !hasAocVerify)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
activity?.SetTag("authority.aoc_scope_violation", "advisory_vex_requires_aoc");
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'aoc:verify' is required when requesting advisory/vex read scopes.");
var violationTag = advisoryAiScopesRequested && !hasAdvisoryRead && !hasVexRead
? "advisory_ai_requires_aoc"
: "advisory_vex_requires_aoc";
activity?.SetTag("authority.aoc_scope_violation", violationTag);
var errorMessage = advisoryAiScopesRequested
? "Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes."
: "Scope 'aoc:verify' is required when requesting advisory/vex read scopes.";
context.Reject(OpenIddictConstants.Errors.InvalidScope, errorMessage);
logger.LogWarning(
"Client credentials validation failed for {ClientId}: advisory/vex read scopes require aoc:verify.",
"Client credentials validation failed for {ClientId}: advisory/advisory-ai/vex read scopes require aoc:verify.",
document.ClientId);
return;
}
@@ -657,6 +777,28 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.QuotaReasonProperty, out var quotaReasonObj) &&
quotaReasonObj is string quotaReason &&
!string.IsNullOrWhiteSpace(quotaReason))
{
extraProperties.Add(new AuthEventProperty
{
Name = "quota.reason",
Value = ClassifiedString.Sensitive(quotaReason)
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.QuotaTicketProperty, out var quotaTicketObj) &&
quotaTicketObj is string quotaTicket &&
!string.IsNullOrWhiteSpace(quotaTicket))
{
extraProperties.Add(new AuthEventProperty
{
Name = "quota.ticket",
Value = ClassifiedString.Sensitive(quotaTicket)
});
}
var record = ClientCredentialsAuditHelper.CreateRecord(
timeProvider,
context.Transaction,
@@ -917,6 +1059,20 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
identity.SetClaim(StellaOpsClaimTypes.OperatorTicket, operatorTicketValueString);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.QuotaReasonProperty, out var quotaReasonValue) &&
quotaReasonValue is string quotaReasonValueString &&
!string.IsNullOrWhiteSpace(quotaReasonValueString))
{
identity.SetClaim(StellaOpsClaimTypes.QuotaReason, quotaReasonValueString);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.QuotaTicketProperty, out var quotaTicketValue) &&
quotaTicketValue is string quotaTicketValueString &&
!string.IsNullOrWhiteSpace(quotaTicketValueString))
{
identity.SetClaim(StellaOpsClaimTypes.QuotaTicket, quotaTicketValueString);
}
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
if (context.IsRejected)
{
@@ -1289,4 +1445,4 @@ internal static class ClientCredentialHandlerHelpers
return null;
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ConfigureAuthorityDiscoveryHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleConfigurationRequestContext>
{
private readonly IOptionsMonitor<StellaOpsAuthorityOptions> optionsMonitor;
public ConfigureAuthorityDiscoveryHandler(IOptionsMonitor<StellaOpsAuthorityOptions> optionsMonitor)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
}
public ValueTask HandleAsync(OpenIddictServerEvents.HandleConfigurationRequestContext context)
{
ArgumentNullException.ThrowIfNull(context);
var authorityOptions = optionsMonitor.CurrentValue ?? throw new InvalidOperationException("Authority configuration is not available.");
context.Metadata["stellaops_advisory_ai_scopes_supported"] = new[]
{
StellaOpsScopes.AdvisoryAiView,
StellaOpsScopes.AdvisoryAiOperate,
StellaOpsScopes.AdvisoryAiAdmin
};
context.Metadata["stellaops_airgap_scopes_supported"] = new[]
{
StellaOpsScopes.AirgapSeal,
StellaOpsScopes.AirgapImport,
StellaOpsScopes.AirgapStatusRead
};
context.Metadata["stellaops_notify_scopes_supported"] = new[]
{
StellaOpsScopes.NotifyViewer,
StellaOpsScopes.NotifyOperator,
StellaOpsScopes.NotifyAdmin
};
context.Metadata["stellaops_observability_scopes_supported"] = new[]
{
StellaOpsScopes.ObservabilityRead,
StellaOpsScopes.TimelineRead,
StellaOpsScopes.TimelineWrite,
StellaOpsScopes.EvidenceCreate,
StellaOpsScopes.EvidenceRead,
StellaOpsScopes.EvidenceHold,
StellaOpsScopes.AttestRead,
StellaOpsScopes.ObservabilityIncident
};
var remote = authorityOptions.AdvisoryAi.RemoteInference;
var remoteMetadata = JsonSerializer.SerializeToElement(new
{
enabled = remote.Enabled,
require_tenant_consent = remote.RequireTenantConsent,
allowed_profiles = remote.AllowedProfiles.Count == 0
? Array.Empty<string>()
: remote.AllowedProfiles.ToArray()
});
context.Metadata["stellaops_advisory_ai_remote_inference"] = new OpenIddictParameter(remoteMetadata);
return ValueTask.CompletedTask;
}
}

View File

@@ -5,18 +5,18 @@ using System.Globalization;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -28,25 +28,25 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<ValidatePasswordGrantHandler> logger;
public ValidatePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthorityClientStore clientStore,
TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly ILogger<ValidatePasswordGrantHandler> logger;
public ValidatePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthorityClientStore clientStore,
TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
@@ -194,14 +194,318 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
if (unexpectedParameters.Count > 0)
{
var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
var tamperRecord = PasswordGrantAuditHelper.CreateTamperRecord(
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
var grantedScopesArray = resolvedScopes.Scopes;
static bool ContainsScope(string[] scopes, string scope)
=> scopes.Length > 0 && Array.IndexOf(scopes, scope) >= 0;
static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
const int IncidentReasonMaxLength = 512;
var hasAdvisoryIngest = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryIngest);
var hasAdvisoryRead = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryRead);
var hasAdvisoryAiView = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryAiView);
var hasAdvisoryAiOperate = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryAiOperate);
var hasAdvisoryAiAdmin = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryAiAdmin);
var advisoryAiScopesRequested = hasAdvisoryAiView || hasAdvisoryAiOperate || hasAdvisoryAiAdmin;
var hasVexIngest = ContainsScope(grantedScopesArray, StellaOpsScopes.VexIngest);
var hasVexRead = ContainsScope(grantedScopesArray, StellaOpsScopes.VexRead);
var hasAocVerify = ContainsScope(grantedScopesArray, StellaOpsScopes.AocVerify);
var hasObservabilityIncident = ContainsScope(grantedScopesArray, StellaOpsScopes.ObservabilityIncident);
var hasSignalsRead = ContainsScope(grantedScopesArray, StellaOpsScopes.SignalsRead);
var hasSignalsWrite = ContainsScope(grantedScopesArray, StellaOpsScopes.SignalsWrite);
var hasSignalsAdmin = ContainsScope(grantedScopesArray, StellaOpsScopes.SignalsAdmin);
var signalsScopesRequested = hasSignalsRead || hasSignalsWrite || hasSignalsAdmin;
var hasPolicyAuthor = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAuthor);
var hasPolicyReview = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyReview);
var hasPolicyOperate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyOperate);
var hasPolicyAudit = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAudit);
var hasPolicyApprove = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyApprove);
var hasPolicyRun = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRun);
var hasPolicyActivate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyActivate);
var hasPolicySimulate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicySimulate);
var hasPolicyRead = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRead);
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
|| hasPolicyOperate
|| hasPolicyAudit
|| hasPolicyApprove
|| hasPolicyRun
|| hasPolicyActivate
|| hasPolicySimulate
|| hasPolicyRead;
var hasExceptionsApprove = ContainsScope(grantedScopesArray, StellaOpsScopes.ExceptionsApprove);
if ((hasAdvisoryIngest || hasAdvisoryRead || advisoryAiScopesRequested) &&
string.IsNullOrWhiteSpace(tenant))
{
var advisoryScope = hasAdvisoryIngest
? StellaOpsScopes.AdvisoryIngest
: hasAdvisoryRead
? StellaOpsScopes.AdvisoryRead
: hasAdvisoryAiAdmin
? StellaOpsScopes.AdvisoryAiAdmin
: hasAdvisoryAiOperate
? StellaOpsScopes.AdvisoryAiOperate
: StellaOpsScopes.AdvisoryAiView;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = advisoryScope;
var requiresAdvisoryAiTenant = advisoryAiScopesRequested && !hasAdvisoryIngest && !hasAdvisoryRead;
var reason = requiresAdvisoryAiTenant
? "Advisory AI scopes require a tenant assignment."
: "Advisory scopes require a tenant assignment.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
reason,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, reason);
logger.LogWarning(
"Password grant validation failed for client {ClientId}: {ScopeType} scopes require tenant assignment.",
clientId,
requiresAdvisoryAiTenant ? "advisory AI" : "advisory");
return;
}
if ((hasVexIngest || hasVexRead) && string.IsNullOrWhiteSpace(tenant))
{
var vexScope = hasVexIngest ? StellaOpsScopes.VexIngest : StellaOpsScopes.VexRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = vexScope;
const string vexReason = "VEX scopes require a tenant assignment.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
vexReason,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, vexReason);
logger.LogWarning(
"Password grant validation failed for client {ClientId}: vex scopes require tenant assignment.",
clientId);
return;
}
if ((hasAdvisoryRead || hasVexRead || advisoryAiScopesRequested) && !hasAocVerify)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
var violationTag = advisoryAiScopesRequested && !hasAdvisoryRead && !hasVexRead
? "advisory_ai_requires_aoc"
: "advisory_vex_requires_aoc";
activity?.SetTag("authority.aoc_scope_violation", violationTag);
const string reason = "Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
reason,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidScope, reason);
logger.LogWarning(
"Password grant validation failed for client {ClientId}: advisory and VEX scopes require aoc:verify.",
clientId);
return;
}
if (hasObservabilityIncident)
{
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.IncidentReasonParameterName)?.Value?.ToString();
var incidentReason = Normalize(reasonRaw);
if (string.IsNullOrWhiteSpace(incidentReason))
{
const string message = "Incident mode activation requires 'incident_reason'.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
message,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.ObservabilityIncident;
context.Reject(OpenIddictConstants.Errors.InvalidRequest, message);
logger.LogWarning("Password grant validation failed for {Username}: incident_reason missing for obs:incident.", context.Request.Username);
return;
}
if (incidentReason.Length > IncidentReasonMaxLength)
{
var message = $"incident_reason must not exceed {IncidentReasonMaxLength} characters.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
message,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.ObservabilityIncident;
context.Reject(OpenIddictConstants.Errors.InvalidRequest, message);
logger.LogWarning("Password grant validation failed for {Username}: incident_reason exceeded length limit.", context.Request.Username);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.IncidentReasonProperty] = incidentReason;
}
if ((signalsScopesRequested || policyStudioScopesRequested) && string.IsNullOrWhiteSpace(tenant))
{
var scopeForAudit = signalsScopesRequested
? (hasSignalsAdmin
? StellaOpsScopes.SignalsAdmin
: hasSignalsWrite
? StellaOpsScopes.SignalsWrite
: StellaOpsScopes.SignalsRead)
: hasPolicyActivate
? StellaOpsScopes.PolicyActivate
: hasPolicyRun
? StellaOpsScopes.PolicyRun
: hasPolicyApprove
? StellaOpsScopes.PolicyApprove
: hasPolicyOperate
? StellaOpsScopes.PolicyOperate
: hasPolicyReview
? StellaOpsScopes.PolicyReview
: hasPolicyAudit
? StellaOpsScopes.PolicyAudit
: hasPolicySimulate
? StellaOpsScopes.PolicySimulate
: hasPolicyRead
? StellaOpsScopes.PolicyRead
: StellaOpsScopes.PolicyAuthor;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = scopeForAudit;
var reason = signalsScopesRequested
? "Signals scopes require a tenant assignment."
: "Policy Studio scopes require a tenant assignment.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
reason,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, reason);
logger.LogWarning(
"Password grant validation failed for client {ClientId}: {ScopeType} scopes require tenant assignment.",
clientId,
signalsScopesRequested ? "signals" : "policy");
return;
}
if (signalsScopesRequested && !hasAocVerify)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
activity?.SetTag("authority.aoc_scope_violation", "signals_requires_aoc");
const string reason = "Scope 'aoc:verify' is required when requesting signals scopes.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
reason,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidScope, reason);
logger.LogWarning(
"Password grant validation failed for client {ClientId}: signals scopes require aoc:verify.",
clientId);
return;
}
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
if (unexpectedParameters.Count > 0)
{
var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
var tamperRecord = PasswordGrantAuditHelper.CreateTamperRecord(
timeProvider,
context.Transaction,
metadata,
@@ -239,11 +543,11 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
context.Reject(selection.Error!, selection.Description);
logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description);
return;
}
var selectedProvider = selection.Provider!;
if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password))
}
var selectedProvider = selection.Provider!;
if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password))
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
@@ -264,13 +568,80 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided.");
logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selectedProvider.Name;
activity?.SetTag("authority.identity_provider", selectedProvider.Name);
logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selectedProvider.Name);
logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username);
return;
}
if (hasExceptionsApprove && !selectedProvider.Capabilities.SupportsMfa)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.ExceptionsApprove;
const string reason = "Exception approval scope requires an MFA-capable identity provider.";
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
reason,
clientId,
providerName: selectedProvider.Name,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidScope, reason);
logger.LogWarning(
"Password grant validation failed for {Username}: exceptions:approve requires MFA-capable provider.",
context.Request.Username);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selectedProvider.Name;
activity?.SetTag("authority.identity_provider", selectedProvider.Name);
if (!context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.AuditSuccessRecordedProperty))
{
List<AuthEventProperty>? extraProperties = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.IncidentReasonProperty, out var incidentReasonObj) &&
incidentReasonObj is string incidentReasonValue &&
!string.IsNullOrWhiteSpace(incidentReasonValue))
{
extraProperties = new List<AuthEventProperty>
{
new()
{
Name = "incident.reason",
Value = ClassifiedString.Sensitive(incidentReasonValue)
}
};
}
var validationSuccess = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Success,
"Password grant validation succeeded.",
clientId,
providerName: selectedProvider.Name,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: null,
extraProperties: extraProperties);
await auditSink.WriteAsync(validationSuccess, context.CancellationToken).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditSuccessRecordedProperty] = true;
}
logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selectedProvider.Name);
}
}
@@ -282,25 +653,25 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly TimeProvider timeProvider;
private readonly ILogger<HandlePasswordGrantHandler> logger;
public HandlePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityClientStore clientStore,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider timeProvider,
ILogger<HandlePasswordGrantHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly ILogger<HandlePasswordGrantHandler> logger;
public HandlePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityClientStore clientStore,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider timeProvider,
ILogger<HandlePasswordGrantHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context)
{
@@ -542,16 +913,27 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role));
}
if (!string.IsNullOrWhiteSpace(tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant);
}
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
OpenIddictConstants.Claims.Name => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
OpenIddictConstants.Claims.PreferredUsername => new[] { OpenIddictConstants.Destinations.AccessToken },
if (!string.IsNullOrWhiteSpace(tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.IncidentReasonProperty, out var incidentReasonValueObj) &&
incidentReasonValueObj is string incidentReasonValue &&
!string.IsNullOrWhiteSpace(incidentReasonValue))
{
identity.SetClaim(StellaOpsClaimTypes.IncidentReason, incidentReasonValue);
activity?.SetTag("authority.incident_reason_present", true);
}
var issuedAt = timeProvider.GetUtcNow();
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
OpenIddictConstants.Claims.Name => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
OpenIddictConstants.Claims.PreferredUsername => new[] { OpenIddictConstants.Destinations.AccessToken },
OpenIddictConstants.Claims.Role => new[] { OpenIddictConstants.Destinations.AccessToken },
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
});
@@ -562,28 +944,53 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Success,
verification.Message,
clientId,
providerMetadata.Name,
tenant,
verification.User,
username,
scopes: grantedScopes,
retryAfter: null,
failureCode: null,
extraProperties: verification.AuditProperties);
await auditSink.WriteAsync(successRecord, context.CancellationToken).ConfigureAwait(false);
context.Principal = principal;
context.HandleRequest();
activity?.SetTag("authority.subject_id", verification.User.SubjectId);
logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
var successAlreadyLogged = context.Transaction.Properties.TryGetValue(
AuthorityOpenIddictConstants.AuditSuccessRecordedProperty,
out var successValue) && successValue is true;
if (!successAlreadyLogged)
{
List<AuthEventProperty>? successProperties = null;
if (verification.AuditProperties is { } existingProperties)
{
successProperties = new List<AuthEventProperty>(existingProperties);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.IncidentReasonProperty, out var successIncidentReasonObj) &&
successIncidentReasonObj is string successIncidentReason &&
!string.IsNullOrWhiteSpace(successIncidentReason))
{
successProperties ??= new List<AuthEventProperty>();
successProperties.Add(new AuthEventProperty
{
Name = "incident.reason",
Value = ClassifiedString.Sensitive(successIncidentReason)
});
}
var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Success,
verification.Message,
clientId,
providerMetadata.Name,
tenant,
verification.User,
username,
scopes: grantedScopes,
retryAfter: null,
failureCode: null,
extraProperties: successProperties);
await auditSink.WriteAsync(successRecord, context.CancellationToken).ConfigureAwait(false);
}
context.Principal = principal;
context.HandleRequest();
activity?.SetTag("authority.subject_id", verification.User.SubjectId);
logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
{
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
public ValidateRefreshTokenGrantHandler(ILogger<ValidateRefreshTokenGrantHandler> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Request.IsRefreshTokenGrantType())
{
return ValueTask.CompletedTask;
}
var requestedScopes = context.Request.GetScopes();
var refreshPrincipal = context.Principal;
if ((requestedScopes.Contains(StellaOpsScopes.ObservabilityIncident) || refreshPrincipal?.HasScope(StellaOpsScopes.ObservabilityIncident) == true))
{
context.Reject(OpenIddictConstants.Errors.InvalidGrant, "obs:incident tokens require fresh authentication; refresh is not permitted.");
logger.LogWarning("Refresh token validation failed for client {ClientId}: obs:incident scope requires fresh authentication.", context.ClientId ?? context.Request.ClientId ?? "<unknown>");
}
return ValueTask.CompletedTask;
}
}

View File

@@ -110,13 +110,19 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
{
document.Project = StellaOpsTenancyDefaults.AnyProject;
}
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (!string.IsNullOrWhiteSpace(senderConstraint))
{
document.SenderConstraint = senderConstraint;
}
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (!string.IsNullOrWhiteSpace(senderConstraint))
{
document.SenderConstraint = senderConstraint;
}
var incidentReason = principal.GetClaim(StellaOpsClaimTypes.IncidentReason);
if (!string.IsNullOrWhiteSpace(incidentReason))
{
document.IncidentReason = incidentReason.Trim();
}
var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
if (!string.IsNullOrWhiteSpace(confirmation))
{

View File

@@ -21,17 +21,18 @@ using StellaOps.Authority.Security;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly IAuthorityClientStore clientStore;
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthEventSink auditSink;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<ValidateAccessTokenHandler> logger;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<ValidateAccessTokenHandler> logger;
private static readonly TimeSpan IncidentFreshAuthWindow = TimeSpan.FromMinutes(5);
public ValidateAccessTokenHandler(
IAuthorityTokenStore tokenStore,
@@ -339,12 +340,35 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
client = await provider.ClientProvisioning.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
}
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
logger.LogInformation("Access token validated for subject {Subject} and client {ClientId}.",
identity.GetClaim(OpenIddictConstants.Claims.Subject),
identity.GetClaim(OpenIddictConstants.Claims.ClientId));
}
if (context.Principal.HasScope(StellaOpsScopes.ObservabilityIncident))
{
var authTimeClaim = context.Principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime);
if (string.IsNullOrWhiteSpace(authTimeClaim) ||
!long.TryParse(authTimeClaim, NumberStyles.Integer, CultureInfo.InvariantCulture, out var authTimeSeconds))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "obs:incident tokens require authentication_time claim.");
logger.LogWarning("Access token validation failed: obs:incident token missing auth_time. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var authTime = DateTimeOffset.FromUnixTimeSeconds(authTimeSeconds);
var now = clock.GetUtcNow();
if (now - authTime > IncidentFreshAuthWindow)
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "obs:incident tokens require fresh authentication.");
logger.LogWarning("Access token validation failed: obs:incident token stale. ClientId={ClientId}; AuthTime={AuthTime:o}; Now={Now:o}; Window={Window}", clientId ?? "<unknown>", authTime, now, IncidentFreshAuthWindow);
return;
}
metadataAccessor.SetTag("authority.incident_scope_validated", "true");
}
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
logger.LogInformation("Access token validated for subject {Subject} and client {ClientId}.",
identity.GetClaim(OpenIddictConstants.Claims.Subject),
identity.GetClaim(OpenIddictConstants.Claims.ClientId));
}
private async ValueTask TrackTokenUsageAsync(
OpenIddictServerEvents.ValidateTokenContext context,

View File

@@ -43,12 +43,16 @@ internal static class TokenRequestTamperInspector
AuthorityOpenIddictConstants.ProviderParameterName
};
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
{
AuthorityOpenIddictConstants.ProviderParameterName,
AuthorityOpenIddictConstants.OperatorReasonParameterName,
AuthorityOpenIddictConstants.OperatorTicketParameterName
};
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
{
AuthorityOpenIddictConstants.ProviderParameterName,
AuthorityOpenIddictConstants.OperatorReasonParameterName,
AuthorityOpenIddictConstants.OperatorTicketParameterName,
AuthorityOpenIddictConstants.ExportAdminReasonParameterName,
AuthorityOpenIddictConstants.ExportAdminTicketParameterName,
AuthorityOpenIddictConstants.QuotaReasonParameterName,
AuthorityOpenIddictConstants.QuotaTicketParameterName
};
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
=> DetectUnexpectedParameters(request, PasswordGrantParameters);

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -57,18 +58,21 @@ internal sealed class AuthorityJwksService
}
var response = new AuthorityJwksResponse(BuildKeys());
var etag = ComputeEtag(response);
var signingOptions = authorityOptions.Signing;
var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero
? signingOptions.JwksCacheLifetime
: TimeSpan.FromMinutes(5);
var expires = timeProvider.GetUtcNow().Add(lifetime);
var etag = ComputeEtag(response, expires);
var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}";
var result = new AuthorityJwksResult(response, etag, expires, cacheControl);
var entry = new AuthorityJwksCacheEntry(result, expires);
cache.Set(CacheKey, entry, expires);
cache.Set(CacheKey, entry, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = lifetime
});
return result;
}
@@ -96,11 +100,20 @@ internal sealed class AuthorityJwksService
{
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
var jwk = signer.ExportPublicJsonWebKey();
var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse)
? metadataUse
: jwk.Use;
if (string.IsNullOrWhiteSpace(keyUse))
{
keyUse = "sig";
}
var entry = new JwksKeyEntry
{
Kid = jwk.Kid,
Kty = jwk.Kty,
Use = string.IsNullOrWhiteSpace(jwk.Use) ? "sig" : jwk.Use,
Use = keyUse,
Alg = jwk.Alg,
Crv = jwk.Crv,
X = jwk.X,
@@ -120,10 +133,11 @@ internal sealed class AuthorityJwksService
return keys;
}
private static string ComputeEtag(AuthorityJwksResponse response)
private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt)
{
var payload = JsonSerializer.Serialize(response, SerializerOptions);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
var hash = SHA256.HashData(buffer);
return $"\"{Convert.ToHexString(hash)}\"";
}

View File

@@ -51,7 +51,7 @@
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
| AUTH-ORCH-34-001 | TODO | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
| AUTH-ORCH-34-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
## StellaOps Console (Sprint 23)
@@ -97,8 +97,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AIAI-31-001 | TODO | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
| AUTH-AIAI-31-002 | TODO | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
## Export Center
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
@@ -107,8 +107,9 @@
## Notifications Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-NOTIFY-38-001 | TODO | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-NOTIFY-40-001 | TODO | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
## CLI Parity & Task Packs
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
@@ -127,22 +128,25 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-OBS-50-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
| AUTH-OBS-52-001 | TODO | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
| AUTH-OBS-55-001 | TODO | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AIRGAP-56-001 | TODO | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
| AUTH-AIRGAP-56-002 | TODO | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
| AUTH-AIRGAP-57-001 | TODO | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | CI scenario validated; error surfaces remediation; docs updated. |
| AUTH-AIRGAP-56-001 | DOING (2025-11-01) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
| AUTH-AIRGAP-56-002 | DOING | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. |
> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild).
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
| AUTH-OAS-62-001 | TODO | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
| AUTH-OAS-63-001 | TODO | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).