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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user