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