Add Authority Advisory AI and API Lifecycle Configuration

- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings.
- Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations.
- Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration.
- Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options.
- Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations.
- Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client.
- Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

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

View File

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