Add OpenSslLegacyShim to ensure OpenSSL 1.1 libraries are accessible on Linux
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit introduces the OpenSslLegacyShim class, which sets the LD_LIBRARY_PATH environment variable to include the directory containing OpenSSL 1.1 native libraries. This is necessary for Mongo2Go to function correctly on Linux platforms that do not ship these libraries by default. The shim checks if the current operating system is Linux and whether the required directory exists before modifying the environment variable.
This commit is contained in:
@@ -28,6 +28,19 @@ public class StellaOpsResourceServerPoliciesTests
|
||||
AssertPolicy(options, StellaOpsResourceServerPolicies.ExportAdmin, StellaOpsScopes.ExportAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPacksResourcePolicies_RegistersExpectedPolicies()
|
||||
{
|
||||
var options = new AuthorizationOptions();
|
||||
|
||||
options.AddPacksResourcePolicies();
|
||||
|
||||
AssertPolicy(options, StellaOpsResourceServerPolicies.PacksRead, StellaOpsScopes.PacksRead);
|
||||
AssertPolicy(options, StellaOpsResourceServerPolicies.PacksWrite, StellaOpsScopes.PacksWrite);
|
||||
AssertPolicy(options, StellaOpsResourceServerPolicies.PacksRun, StellaOpsScopes.PacksRun);
|
||||
AssertPolicy(options, StellaOpsResourceServerPolicies.PacksApprove, StellaOpsScopes.PacksApprove);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(AuthorizationOptions options, string policyName, string expectedScope)
|
||||
{
|
||||
var policy = options.GetPolicy(policyName);
|
||||
|
||||
@@ -290,7 +290,70 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
Assert.Equal(freshAuthTime.ToString("o", CultureInfo.InvariantCulture), GetPropertyValue(record, "incident.auth_time"));
|
||||
Assert.Equal("Sev1 drill", GetPropertyValue(record, "incident.reason"));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenBackfillMetadataMissing()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.RequiredTenants.Add("tenant-alpha");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("10.0.0.77"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.OrchBackfill });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("orch-admin")
|
||||
.WithClientId("orch-control")
|
||||
.WithTenant("tenant-alpha")
|
||||
.WithScopes(new[] { StellaOpsScopes.OrchBackfill })
|
||||
.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("Backfill scope requires reason and ticket.", record.Reason);
|
||||
Assert.Equal("false", GetPropertyValue(record, "backfill.metadata_satisfied"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenBackfillMetadataPresent()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.RequiredTenants.Add("tenant-alpha");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("10.0.0.88"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.OrchBackfill });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("orch-admin")
|
||||
.WithClientId("orch-control")
|
||||
.WithTenant("tenant-alpha")
|
||||
.WithScopes(new[] { StellaOpsScopes.OrchBackfill })
|
||||
.AddClaim(StellaOpsClaimTypes.BackfillReason, "Quota recovery backfill")
|
||||
.AddClaim(StellaOpsClaimTypes.BackfillTicket, "INC-741")
|
||||
.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, "backfill.metadata_satisfied"));
|
||||
Assert.Equal("Quota recovery backfill", GetPropertyValue(record, "backfill.reason"));
|
||||
Assert.Equal("INC-741", GetPropertyValue(record, "backfill.ticket"));
|
||||
}
|
||||
|
||||
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor, RecordingAuthEventSink Sink) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress, TimeProvider? timeProvider = null)
|
||||
{
|
||||
var accessor = new HttpContextAccessor();
|
||||
|
||||
@@ -64,6 +64,26 @@ public static class StellaOpsResourceServerPolicies
|
||||
/// </summary>
|
||||
public const string ExportAdmin = StellaOpsScopes.ExportAdmin;
|
||||
|
||||
/// <summary>
|
||||
/// Pack read policy name.
|
||||
/// </summary>
|
||||
public const string PacksRead = StellaOpsScopes.PacksRead;
|
||||
|
||||
/// <summary>
|
||||
/// Pack write policy name.
|
||||
/// </summary>
|
||||
public const string PacksWrite = StellaOpsScopes.PacksWrite;
|
||||
|
||||
/// <summary>
|
||||
/// Pack run policy name.
|
||||
/// </summary>
|
||||
public const string PacksRun = StellaOpsScopes.PacksRun;
|
||||
|
||||
/// <summary>
|
||||
/// Pack approval policy name.
|
||||
/// </summary>
|
||||
public const string PacksApprove = StellaOpsScopes.PacksApprove;
|
||||
|
||||
/// <summary>
|
||||
/// Registers all observability, timeline, evidence, attestation, and export authorization policies.
|
||||
/// </summary>
|
||||
@@ -83,4 +103,18 @@ public static class StellaOpsResourceServerPolicies
|
||||
options.AddStellaOpsScopePolicy(ExportOperator, StellaOpsScopes.ExportOperator);
|
||||
options.AddStellaOpsScopePolicy(ExportAdmin, StellaOpsScopes.ExportAdmin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Task Pack registry, execution, and approval authorization policies.
|
||||
/// </summary>
|
||||
/// <param name="options">The authorization options to update.</param>
|
||||
public static void AddPacksResourcePolicies(this AuthorizationOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.AddStellaOpsScopePolicy(PacksRead, StellaOpsScopes.PacksRead);
|
||||
options.AddStellaOpsScopePolicy(PacksWrite, StellaOpsScopes.PacksWrite);
|
||||
options.AddStellaOpsScopePolicy(PacksRun, StellaOpsScopes.PacksRun);
|
||||
options.AddStellaOpsScopePolicy(PacksApprove, StellaOpsScopes.PacksApprove);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,10 +98,19 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
string? incidentReasonClaim = null;
|
||||
DateTimeOffset? incidentAuthTime = null;
|
||||
string? incidentFailureReason = null;
|
||||
var backfillMetadataRequired = combinedScopes.Contains(StellaOpsScopes.OrchBackfill);
|
||||
var backfillMetadataSatisfied = true;
|
||||
string? backfillReasonClaim = null;
|
||||
string? backfillTicketClaim = null;
|
||||
string? backfillFailureReason = null;
|
||||
|
||||
if (principalAuthenticated)
|
||||
{
|
||||
incidentReasonClaim = principal!.FindFirstValue(StellaOpsClaimTypes.IncidentReason);
|
||||
backfillReasonClaim = principal!.FindFirstValue(StellaOpsClaimTypes.BackfillReason);
|
||||
backfillTicketClaim = principal!.FindFirstValue(StellaOpsClaimTypes.BackfillTicket);
|
||||
backfillReasonClaim = backfillReasonClaim?.Trim();
|
||||
backfillTicketClaim = backfillTicketClaim?.Trim();
|
||||
}
|
||||
|
||||
if (principalAuthenticated && allScopesSatisfied)
|
||||
@@ -119,6 +128,15 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
out incidentFailureReason);
|
||||
}
|
||||
|
||||
if (principalAuthenticated && tenantAllowed && allScopesSatisfied && backfillMetadataRequired)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(backfillReasonClaim) || string.IsNullOrWhiteSpace(backfillTicketClaim))
|
||||
{
|
||||
backfillMetadataSatisfied = false;
|
||||
backfillFailureReason = "Backfill scope requires reason and ticket.";
|
||||
}
|
||||
}
|
||||
|
||||
var bypassed = false;
|
||||
|
||||
if ((!principalAuthenticated || !allScopesSatisfied || !tenantAllowed || !incidentFreshAuthSatisfied) &&
|
||||
@@ -133,10 +151,12 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
incidentFreshAuthSatisfied = true;
|
||||
incidentFailureReason = null;
|
||||
incidentAuthTime = null;
|
||||
backfillMetadataSatisfied = true;
|
||||
backfillFailureReason = null;
|
||||
bypassed = true;
|
||||
}
|
||||
|
||||
if (tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied)
|
||||
if (tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied && backfillMetadataSatisfied)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
@@ -181,9 +201,18 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
ObservabilityIncidentFreshAuthWindow,
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
if (backfillMetadataRequired && !backfillMetadataSatisfied)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Backfill scope metadata requirement not satisfied. ReasonPresent={ReasonPresent}; TicketPresent={TicketPresent}; Remote={Remote}",
|
||||
!string.IsNullOrWhiteSpace(backfillReasonClaim),
|
||||
!string.IsNullOrWhiteSpace(backfillTicketClaim),
|
||||
httpContext?.Connection.RemoteIpAddress);
|
||||
}
|
||||
}
|
||||
|
||||
var reason = incidentFailureReason ?? DetermineFailureReason(
|
||||
var reason = backfillFailureReason ?? incidentFailureReason ?? DetermineFailureReason(
|
||||
principalAuthenticated,
|
||||
allScopesSatisfied,
|
||||
anyScopeMatched,
|
||||
@@ -202,7 +231,7 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
resourceOptions,
|
||||
normalizedTenant,
|
||||
missingScopes,
|
||||
tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied,
|
||||
tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied && backfillMetadataSatisfied,
|
||||
bypassed,
|
||||
reason,
|
||||
principalAuthenticated,
|
||||
@@ -212,7 +241,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReasonClaim,
|
||||
incidentAuthTime).ConfigureAwait(false);
|
||||
incidentAuthTime,
|
||||
backfillMetadataRequired,
|
||||
backfillMetadataSatisfied,
|
||||
backfillReasonClaim,
|
||||
backfillTicketClaim).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? DetermineFailureReason(
|
||||
@@ -293,7 +326,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
DateTimeOffset? incidentAuthTime,
|
||||
bool backfillMetadataRequired,
|
||||
bool backfillMetadataSatisfied,
|
||||
string? backfillReason,
|
||||
string? backfillTicket)
|
||||
{
|
||||
if (!auditSinks.Any())
|
||||
{
|
||||
@@ -320,7 +357,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReason,
|
||||
incidentAuthTime);
|
||||
incidentAuthTime,
|
||||
backfillMetadataRequired,
|
||||
backfillMetadataSatisfied,
|
||||
backfillReason,
|
||||
backfillTicket);
|
||||
|
||||
var cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None;
|
||||
|
||||
@@ -353,7 +394,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
DateTimeOffset? incidentAuthTime,
|
||||
bool backfillMetadataRequired,
|
||||
bool backfillMetadataSatisfied,
|
||||
string? backfillReason,
|
||||
string? backfillTicket)
|
||||
{
|
||||
var correlationId = ResolveCorrelationId(httpContext);
|
||||
var subject = BuildSubject(principal);
|
||||
@@ -373,7 +418,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
incidentFreshAuthRequired,
|
||||
incidentFreshAuthSatisfied,
|
||||
incidentReason,
|
||||
incidentAuthTime);
|
||||
incidentAuthTime,
|
||||
backfillMetadataRequired,
|
||||
backfillMetadataSatisfied,
|
||||
backfillReason,
|
||||
backfillTicket);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
@@ -403,7 +452,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
bool incidentFreshAuthRequired,
|
||||
bool incidentFreshAuthSatisfied,
|
||||
string? incidentReason,
|
||||
DateTimeOffset? incidentAuthTime)
|
||||
DateTimeOffset? incidentAuthTime,
|
||||
bool backfillMetadataRequired,
|
||||
bool backfillMetadataSatisfied,
|
||||
string? backfillReason,
|
||||
string? backfillTicket)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
@@ -507,6 +560,33 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
}
|
||||
}
|
||||
|
||||
if (backfillMetadataRequired)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "backfill.metadata_satisfied",
|
||||
Value = ClassifiedString.Public(backfillMetadataSatisfied ? "true" : "false")
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backfillReason))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "backfill.reason",
|
||||
Value = ClassifiedString.Sensitive(backfillReason!)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backfillTicket))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "backfill.ticket",
|
||||
Value = ClassifiedString.Sensitive(backfillTicket!)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
|
||||
public AuthorityWebApplicationFactory()
|
||||
{
|
||||
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true, singleNodeReplSetWaitTimeout: 120);
|
||||
tempContentRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "stellaops-authority-tests", Guid.NewGuid().ToString("N"));
|
||||
System.IO.Directory.CreateDirectory(tempContentRoot);
|
||||
|
||||
@@ -105,30 +105,32 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
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;
|
||||
}
|
||||
public override async ValueTask 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
|
||||
}
|
||||
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Task IAsyncLifetime.DisposeAsync() => DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureTestServices(services =>
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
@@ -138,7 +138,7 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureTestServices(services =>
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
|
||||
@@ -666,6 +666,225 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill orch:read");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill orch:read",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Backfill actions require 'backfill_reason'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill orch:read",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Backfill actions require 'backfill_ticket'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonTooLong()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var longReason = new string('a', 257);
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason);
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Backfill reason must not exceed 256 characters.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketTooLong()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var longTicket = new string('b', 129);
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket);
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Backfill ticket must not exceed 128 characters.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_PopulatesBackfillMetadata_OnSuccess()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:backfill orch:read",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "orch:backfill" }, grantedScopes);
|
||||
var reason = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty]);
|
||||
Assert.Equal("Backfill drift repair", reason);
|
||||
var ticket = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty]);
|
||||
Assert.Equal("INC-9981", ticket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket()
|
||||
{
|
||||
|
||||
@@ -14,4 +14,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
|
||||
<None Include="../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Testing;
|
||||
|
||||
internal static class TestEnvironment
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Initialize()
|
||||
{
|
||||
OpenSslLegacyShim.EnsureOpenSsl11();
|
||||
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ISSUER", "https://authority.test");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_STORAGE__CONNECTIONSTRING", "mongodb://localhost/authority");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SIGNING__ENABLED", "false");
|
||||
|
||||
@@ -42,4 +42,8 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string QuotaTicketProperty = "authority:quota_ticket";
|
||||
internal const string QuotaReasonParameterName = "quota_reason";
|
||||
internal const string QuotaTicketParameterName = "quota_ticket";
|
||||
internal const string BackfillReasonProperty = "authority:backfill_reason";
|
||||
internal const string BackfillTicketProperty = "authority:backfill_ticket";
|
||||
internal const string BackfillReasonParameterName = "backfill_reason";
|
||||
internal const string BackfillTicketParameterName = "backfill_ticket";
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
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 hasOrchBackfill = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchBackfill) >= 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;
|
||||
@@ -437,13 +438,15 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if ((hasOrchRead || hasOrchOperate || hasOrchQuota) && !EnsureTenantAssigned())
|
||||
if ((hasOrchRead || hasOrchOperate || hasOrchQuota || hasOrchBackfill) && !EnsureTenantAssigned())
|
||||
{
|
||||
var invalidScope = hasOrchQuota
|
||||
? StellaOpsScopes.OrchQuota
|
||||
: hasOrchOperate
|
||||
? StellaOpsScopes.OrchOperate
|
||||
: StellaOpsScopes.OrchRead;
|
||||
: hasOrchBackfill
|
||||
? StellaOpsScopes.OrchBackfill
|
||||
: hasOrchOperate
|
||||
? StellaOpsScopes.OrchOperate
|
||||
: StellaOpsScopes.OrchRead;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
@@ -529,6 +532,46 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOrchBackfill)
|
||||
{
|
||||
var backfillReasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName)?.Value?.ToString();
|
||||
var backfillReason = NormalizeMetadata(backfillReasonRaw);
|
||||
if (string.IsNullOrWhiteSpace(backfillReason))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Backfill actions require 'backfill_reason'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: backfill_reason missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (backfillReason.Length > 256)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Backfill reason must not exceed 256 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: backfill_reason exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var backfillTicketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName)?.Value?.ToString();
|
||||
var backfillTicket = NormalizeMetadata(backfillTicketRaw);
|
||||
if (string.IsNullOrWhiteSpace(backfillTicket))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Backfill actions require 'backfill_ticket'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: backfill_ticket missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (backfillTicket.Length > 128)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Backfill ticket must not exceed 128 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: backfill_ticket exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty] = backfillReason;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty] = backfillTicket;
|
||||
activity?.SetTag("authority.backfill_reason_present", true);
|
||||
activity?.SetTag("authority.backfill_ticket_present", true);
|
||||
}
|
||||
|
||||
if (hasExportAdmin)
|
||||
{
|
||||
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName)?.Value?.ToString();
|
||||
@@ -789,7 +832,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.QuotaTicketProperty, out var quotaTicketObj) &&
|
||||
quotaTicketObj is string quotaTicket &&
|
||||
quotaTicketObj is string quotaTicket &&
|
||||
!string.IsNullOrWhiteSpace(quotaTicket))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
@@ -799,6 +842,28 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.BackfillReasonProperty, out var backfillReasonObj) &&
|
||||
backfillReasonObj is string backfillReason &&
|
||||
!string.IsNullOrWhiteSpace(backfillReason))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "backfill.reason",
|
||||
Value = ClassifiedString.Sensitive(backfillReason)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.BackfillTicketProperty, out var backfillTicketObj) &&
|
||||
backfillTicketObj is string backfillTicket &&
|
||||
!string.IsNullOrWhiteSpace(backfillTicket))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "backfill.ticket",
|
||||
Value = ClassifiedString.Sensitive(backfillTicket)
|
||||
});
|
||||
}
|
||||
|
||||
var record = ClientCredentialsAuditHelper.CreateRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
@@ -1073,6 +1138,20 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
identity.SetClaim(StellaOpsClaimTypes.QuotaTicket, quotaTicketValueString);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.BackfillReasonProperty, out var backfillReasonValue) &&
|
||||
backfillReasonValue is string backfillReasonValueString &&
|
||||
!string.IsNullOrWhiteSpace(backfillReasonValueString))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.BackfillReason, backfillReasonValueString);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.BackfillTicketProperty, out var backfillTicketValue) &&
|
||||
backfillTicketValue is string backfillTicketValueString &&
|
||||
!string.IsNullOrWhiteSpace(backfillTicketValueString))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.BackfillTicket, backfillTicketValueString);
|
||||
}
|
||||
|
||||
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
|
||||
if (context.IsRejected)
|
||||
{
|
||||
|
||||
@@ -1265,7 +1265,18 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
try
|
||||
{
|
||||
var result = ackManager.Rotate(request);
|
||||
request.KeyId = trimmedKeyId;
|
||||
request.Location = trimmedLocation;
|
||||
|
||||
logger.LogDebug(
|
||||
"Attempting ack token rotation with keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}', algorithm='{Algorithm}'",
|
||||
trimmedKeyId,
|
||||
trimmedLocation,
|
||||
request.Provider ?? ackOptions.Provider,
|
||||
request.Source ?? ackOptions.KeySource,
|
||||
request.Algorithm ?? ackOptions.Algorithm);
|
||||
|
||||
var result = ackManager.Rotate(request);
|
||||
ackLogger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
return Results.Ok(new
|
||||
@@ -1374,138 +1385,197 @@ app.MapPost("/permalinks/vuln", async (
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.VulnRead))
|
||||
.WithName("CreateVulnPermalink");
|
||||
|
||||
app.MapPost("/notify/ack-tokens/rotate", async (
|
||||
HttpContext context,
|
||||
SigningRotationRequest? request,
|
||||
AuthorityAckTokenKeyManager ackManager,
|
||||
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AuthorityAckTokenKeyManager> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopes = ExtractScopes(context.User);
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
const string message = "Request payload is required.";
|
||||
logger.LogWarning("Ack token rotation request payload missing.");
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "invalid_request", message });
|
||||
}
|
||||
|
||||
var notifications = optionsAccessor.Value.Notifications ?? throw new InvalidOperationException("Authority notifications configuration is missing.");
|
||||
var ackOptions = notifications.AckTokens ?? throw new InvalidOperationException("Ack token configuration is missing.");
|
||||
|
||||
if (!ackOptions.Enabled)
|
||||
{
|
||||
const string message = "Ack tokens are disabled. Enable notifications.ackTokens before rotating keys.";
|
||||
logger.LogWarning("Ack token rotation attempted while ack tokens are disabled.");
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
request.KeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "ack_tokens_disabled", message });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = ackManager.Rotate(request);
|
||||
logger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Success,
|
||||
result.ActiveKeyId,
|
||||
result.PreviousKeyId,
|
||||
result.RetiredKeyIds,
|
||||
result.ActiveProvider,
|
||||
result.ActiveSource,
|
||||
reason: null,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
activeKeyId = result.ActiveKeyId,
|
||||
provider = result.ActiveProvider,
|
||||
source = result.ActiveSource,
|
||||
location = result.ActiveLocation,
|
||||
previousKeyId = result.PreviousKeyId,
|
||||
retiredKeyIds = result.RetiredKeyIds
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Ack token rotation failed due to invalid input.");
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
request.KeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ex.Message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "rotation_failed", message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected failure rotating ack token key.");
|
||||
|
||||
const string message = "Unexpected failure rotating ack token key.";
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
request.KeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Problem("Failed to rotate ack token key.");
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.NotifyAdmin))
|
||||
.WithName("RotateNotifyAckTokenKey");
|
||||
|
||||
app.MapPost("/notify/ack-tokens/issue", async (
|
||||
app.MapPost("/notify/ack-tokens/rotate", async (
|
||||
HttpContext context,
|
||||
SigningRotationRequest? request,
|
||||
AuthorityAckTokenKeyManager ackManager,
|
||||
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AuthorityAckTokenKeyManager> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopes = ExtractScopes(context.User);
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
const string message = "Request payload is required.";
|
||||
logger.LogWarning("Ack token rotation request payload missing.");
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "invalid_request", message });
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Ack token rotation request received. keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}'",
|
||||
request.KeyId,
|
||||
request.Location,
|
||||
request.Provider,
|
||||
request.Source);
|
||||
|
||||
var notifications = optionsAccessor.Value.Notifications ?? throw new InvalidOperationException("Authority notifications configuration is missing.");
|
||||
var ackOptions = notifications.AckTokens ?? throw new InvalidOperationException("Ack token configuration is missing.");
|
||||
|
||||
var keyId = request.KeyId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
const string message = "Ack token key rotation requires a keyId.";
|
||||
logger.LogWarning("Ack token rotation rejected: missing keyId.");
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
activeKeyId: null,
|
||||
previousKeyId: null,
|
||||
retiredKeyIds: null,
|
||||
provider: null,
|
||||
source: null,
|
||||
reason: message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "invalid_request", message });
|
||||
}
|
||||
|
||||
var location = request.Location?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
const string message = "Ack token key rotation requires a key path/location.";
|
||||
logger.LogWarning("Ack token rotation rejected: missing key path/location.");
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
activeKeyId: keyId,
|
||||
previousKeyId: null,
|
||||
retiredKeyIds: null,
|
||||
provider: null,
|
||||
source: null,
|
||||
reason: message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "invalid_request", message });
|
||||
}
|
||||
|
||||
var trimmedKeyId = keyId!;
|
||||
var trimmedLocation = location!;
|
||||
|
||||
if (!ackOptions.Enabled)
|
||||
{
|
||||
const string message = "Ack tokens are disabled. Enable notifications.ackTokens before rotating keys.";
|
||||
logger.LogWarning("Ack token rotation attempted while ack tokens are disabled.");
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
trimmedKeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "ack_tokens_disabled", message });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
request.KeyId = trimmedKeyId;
|
||||
request.Location = trimmedLocation;
|
||||
|
||||
var result = ackManager.Rotate(request);
|
||||
logger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Success,
|
||||
result.ActiveKeyId,
|
||||
result.PreviousKeyId,
|
||||
result.RetiredKeyIds,
|
||||
result.ActiveProvider,
|
||||
result.ActiveSource,
|
||||
reason: null,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
activeKeyId = result.ActiveKeyId,
|
||||
provider = result.ActiveProvider,
|
||||
source = result.ActiveSource,
|
||||
location = result.ActiveLocation,
|
||||
previousKeyId = result.PreviousKeyId,
|
||||
retiredKeyIds = result.RetiredKeyIds
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Ack token rotation failed due to invalid input.");
|
||||
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
request.KeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ex.Message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "rotation_failed", message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected failure rotating ack token key.");
|
||||
|
||||
const string message = "Unexpected failure rotating ack token key.";
|
||||
await WriteAckRotationAuditAsync(
|
||||
context,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
AuthEventOutcome.Failure,
|
||||
request.KeyId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
message,
|
||||
scopes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Problem("Failed to rotate ack token key.");
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.NotifyAdmin))
|
||||
.WithName("RotateNotifyAckTokenKey");
|
||||
|
||||
app.MapPost("/notify/ack-tokens/issue", async (
|
||||
HttpContext httpContext,
|
||||
AckTokenIssueRequest request,
|
||||
AuthorityAckTokenIssuer issuer,
|
||||
@@ -1775,92 +1845,92 @@ static AuthEventClient? BuildClientContext(ClaimsPrincipal principal)
|
||||
};
|
||||
}
|
||||
|
||||
static async Task WriteAckRotationAuditAsync(
|
||||
HttpContext context,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
AuthEventOutcome outcome,
|
||||
string? activeKeyId,
|
||||
string? previousKeyId,
|
||||
IReadOnlyCollection<string>? retiredKeyIds,
|
||||
string? provider,
|
||||
string? source,
|
||||
string? reason,
|
||||
IReadOnlyList<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var eventType = outcome == AuthEventOutcome.Success
|
||||
? "notify.ack.key_rotated"
|
||||
: "notify.ack.key_rotation_failed";
|
||||
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(activeKeyId))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.key_id",
|
||||
Value = ClassifiedString.Public(activeKeyId)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previousKeyId))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.previous_key_id",
|
||||
Value = ClassifiedString.Public(previousKeyId)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.provider",
|
||||
Value = ClassifiedString.Public(provider)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.source",
|
||||
Value = ClassifiedString.Public(source)
|
||||
});
|
||||
}
|
||||
|
||||
if (retiredKeyIds is { Count: > 0 })
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.retired_key_ids",
|
||||
Value = ClassifiedString.Public(string.Join(",", retiredKeyIds))
|
||||
});
|
||||
}
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Client = BuildClientContext(context.User),
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Scopes = scopes,
|
||||
Network = BuildNetwork(context),
|
||||
Properties = properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static async Task WriteAckAuditAsync(
|
||||
HttpContext context,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
static async Task WriteAckRotationAuditAsync(
|
||||
HttpContext context,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
AuthEventOutcome outcome,
|
||||
string? activeKeyId,
|
||||
string? previousKeyId,
|
||||
IReadOnlyCollection<string>? retiredKeyIds,
|
||||
string? provider,
|
||||
string? source,
|
||||
string? reason,
|
||||
IReadOnlyList<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var eventType = outcome == AuthEventOutcome.Success
|
||||
? "notify.ack.key_rotated"
|
||||
: "notify.ack.key_rotation_failed";
|
||||
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(activeKeyId))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.key_id",
|
||||
Value = ClassifiedString.Public(activeKeyId)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previousKeyId))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.previous_key_id",
|
||||
Value = ClassifiedString.Public(previousKeyId)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.provider",
|
||||
Value = ClassifiedString.Public(provider)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.source",
|
||||
Value = ClassifiedString.Public(source)
|
||||
});
|
||||
}
|
||||
|
||||
if (retiredKeyIds is { Count: > 0 })
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "notify.ack.retired_key_ids",
|
||||
Value = ClassifiedString.Public(string.Join(",", retiredKeyIds))
|
||||
});
|
||||
}
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Client = BuildClientContext(context.User),
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Scopes = scopes,
|
||||
Network = BuildNetwork(context),
|
||||
Properties = properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static async Task WriteAckAuditAsync(
|
||||
HttpContext context,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
string eventType,
|
||||
AuthEventOutcome outcome,
|
||||
AckTokenPayload payload,
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
> 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 | 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. |
|
||||
| AUTH-ORCH-34-001 | DONE (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. |
|
||||
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
@@ -114,7 +115,9 @@
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
| AUTH-PACKS-41-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
|
||||
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: Pack scopes (`AUTH-PACKS-41-001`) and Task Runner pack approvals (`ORCH-SVC-42-101`, `TASKRUN-42-001`) are still TODO. Authority lacks baseline `Packs.*` scope definitions and approval/audit endpoints to enforce policies. Revisit once dependent teams deliver scope catalog + Task Runner approval API.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user