Add OpenSslLegacyShim to ensure OpenSSL 1.1 libraries are accessible on Linux
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:
master
2025-11-02 21:41:03 +02:00
parent f98cea3bcf
commit 1d962ee6fc
71 changed files with 3675 additions and 1255 deletions

View File

@@ -0,0 +1,13 @@
namespace StellaOps.AdvisoryAI.Abstractions;
public sealed record AdvisoryRetrievalRequest(
string AdvisoryKey,
IReadOnlyCollection<string>? PreferredSections = null,
int? MaxChunks = null)
{
public string AdvisoryKey { get; } = AdvisoryKey ?? throw new ArgumentNullException(nameof(AdvisoryKey));
public IReadOnlyCollection<string>? PreferredSections { get; } = PreferredSections;
public int? MaxChunks { get; } = MaxChunks;
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Abstractions;
public sealed class AdvisoryRetrievalResult
{
private AdvisoryRetrievalResult(
string advisoryKey,
IReadOnlyList<AdvisoryChunk> chunks,
IReadOnlyDictionary<string, string> metadata)
{
AdvisoryKey = advisoryKey;
Chunks = chunks;
Metadata = metadata;
}
public string AdvisoryKey { get; }
public IReadOnlyList<AdvisoryChunk> Chunks { get; }
public IReadOnlyDictionary<string, string> Metadata { get; }
public static AdvisoryRetrievalResult Create(
string advisoryKey,
IEnumerable<AdvisoryChunk> chunks,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
ArgumentNullException.ThrowIfNull(chunks);
var chunkList = chunks.ToImmutableArray();
return new AdvisoryRetrievalResult(
advisoryKey,
chunkList,
metadata is null ? ImmutableDictionary<string, string>.Empty : metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
}

View File

@@ -0,0 +1,8 @@
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Abstractions;
public interface IAdvisoryDocumentProvider
{
Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.AdvisoryAI.Abstractions;
public interface IAdvisoryStructuredRetriever
{
Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.AdvisoryAI.Abstractions;
public interface IAdvisoryVectorRetriever
{
Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken);
}
public sealed record VectorRetrievalRequest(
AdvisoryRetrievalRequest Retrieval,
string Query,
int TopK = 5)
{
public AdvisoryRetrievalRequest Retrieval { get; } = Retrieval ?? throw new ArgumentNullException(nameof(Retrieval));
public string Query { get; } = Query ?? throw new ArgumentNullException(nameof(Query));
public int TopK { get; } = TopK;
}
public sealed record VectorRetrievalMatch(
string DocumentId,
string ChunkId,
string Text,
double Score,
IReadOnlyDictionary<string, string> Metadata);

View File

@@ -0,0 +1,26 @@
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal sealed class DocumentChunkerFactory
{
private readonly IReadOnlyList<IDocumentChunker> _chunkers;
public DocumentChunkerFactory(IEnumerable<IDocumentChunker> chunkers)
{
_chunkers = chunkers.ToList();
}
public IDocumentChunker Resolve(DocumentFormat format)
{
foreach (var chunker in _chunkers)
{
if (chunker.CanHandle(format))
{
return chunker;
}
}
throw new NotSupportedException($"No chunker registered for format {format}.");
}
}

View File

@@ -0,0 +1,10 @@
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal interface IDocumentChunker
{
bool CanHandle(DocumentFormat format);
IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document);
}

View File

@@ -0,0 +1,64 @@
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Documents;
public sealed class AdvisoryChunk
{
private AdvisoryChunk(
string documentId,
string chunkId,
string section,
string paragraphId,
string text,
IReadOnlyDictionary<string, string>? metadata,
float[]? embedding)
{
DocumentId = documentId;
ChunkId = chunkId;
Section = section;
ParagraphId = paragraphId;
Text = text;
Metadata = metadata is null
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
Embedding = embedding;
}
public string DocumentId { get; }
public string ChunkId { get; }
public string Section { get; }
public string ParagraphId { get; }
public string Text { get; }
public IReadOnlyDictionary<string, string> Metadata { get; }
public float[]? Embedding { get; private set; }
public AdvisoryChunk WithEmbedding(float[] embedding)
{
ArgumentNullException.ThrowIfNull(embedding);
Embedding = embedding;
return this;
}
public static AdvisoryChunk Create(
string documentId,
string chunkId,
string section,
string paragraphId,
string text,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
ArgumentException.ThrowIfNullOrWhiteSpace(chunkId);
ArgumentException.ThrowIfNullOrWhiteSpace(section);
ArgumentException.ThrowIfNullOrWhiteSpace(paragraphId);
ArgumentNullException.ThrowIfNull(text);
return new AdvisoryChunk(documentId, chunkId, section, paragraphId, text, metadata, embedding: null);
}
}

View File

@@ -0,0 +1,46 @@
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Documents;
public sealed class AdvisoryDocument
{
private AdvisoryDocument(
string documentId,
DocumentFormat format,
string source,
string content,
IReadOnlyDictionary<string, string>? metadata)
{
DocumentId = documentId;
Format = format;
Source = source;
Content = content;
Metadata = metadata is null
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
}
public string DocumentId { get; }
public DocumentFormat Format { get; }
public string Source { get; }
public string Content { get; }
public IReadOnlyDictionary<string, string> Metadata { get; }
public static AdvisoryDocument Create(
string documentId,
DocumentFormat format,
string source,
string content,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentNullException.ThrowIfNull(content);
return new AdvisoryDocument(documentId, format, source, content, metadata);
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.AdvisoryAI.Documents;
public enum DocumentFormat
{
Unknown = 0,
Csaf,
Osv,
Markdown,
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.2" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
# Advisory AI Task Board — Epic 8
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AIAI-31-001 | TODO | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
| AIAI-31-001 | DOING (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
| AIAI-31-002 | TODO | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |

View File

@@ -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);

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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);

View File

@@ -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()
{

View File

@@ -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>

View File

@@ -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");

View File

@@ -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";
}

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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.

View File

@@ -33,22 +33,32 @@ internal static class AuthorityTokenUtilities
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
if (!string.IsNullOrWhiteSpace(scope) && scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
{
var reasonHash = HashOperatorMetadata(options.Authority.OperatorReason);
var ticketHash = HashOperatorMetadata(options.Authority.OperatorTicket);
cacheKey = $"{cacheKey}|op_reason:{reasonHash}|op_ticket:{ticketHash}";
}
return cacheKey;
}
private static string HashOperatorMetadata(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "none";
}
if (!string.IsNullOrWhiteSpace(scope))
{
if (scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
{
var reasonHash = HashMetadata(options.Authority.OperatorReason);
var ticketHash = HashMetadata(options.Authority.OperatorTicket);
cacheKey = $"{cacheKey}|op_reason:{reasonHash}|op_ticket:{ticketHash}";
}
if (scope.Contains("orch:backfill", StringComparison.OrdinalIgnoreCase))
{
var reasonHash = HashMetadata(options.Authority.BackfillReason);
var ticketHash = HashMetadata(options.Authority.BackfillTicket);
cacheKey = $"{cacheKey}|bf_reason:{reasonHash}|bf_ticket:{ticketHash}";
}
}
return cacheKey;
}
private static string HashMetadata(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "none";
}
var trimmed = value.Trim();
var bytes = Encoding.UTF8.GetBytes(trimmed);

View File

@@ -111,28 +111,44 @@ public static class CliBootstrapper
"StellaOps:Authority:OperatorReason",
"Authority:OperatorReason");
authority.OperatorTicket = ResolveWithFallback(
authority.OperatorTicket,
configuration,
"STELLAOPS_ORCH_TICKET",
"StellaOps:Authority:OperatorTicket",
"Authority:OperatorTicket");
authority.TokenCacheDirectory = ResolveWithFallback(
authority.TokenCacheDirectory,
configuration,
"STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR",
"StellaOps:Authority:TokenCacheDirectory",
authority.OperatorTicket = ResolveWithFallback(
authority.OperatorTicket,
configuration,
"STELLAOPS_ORCH_TICKET",
"StellaOps:Authority:OperatorTicket",
"Authority:OperatorTicket");
authority.BackfillReason = ResolveWithFallback(
authority.BackfillReason,
configuration,
"STELLAOPS_ORCH_BACKFILL_REASON",
"StellaOps:Authority:BackfillReason",
"Authority:BackfillReason");
authority.BackfillTicket = ResolveWithFallback(
authority.BackfillTicket,
configuration,
"STELLAOPS_ORCH_BACKFILL_TICKET",
"StellaOps:Authority:BackfillTicket",
"Authority:BackfillTicket");
authority.TokenCacheDirectory = ResolveWithFallback(
authority.TokenCacheDirectory,
configuration,
"STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR",
"StellaOps:Authority:TokenCacheDirectory",
"Authority:TokenCacheDirectory");
authority.Url = authority.Url?.Trim() ?? string.Empty;
authority.ClientId = authority.ClientId?.Trim() ?? string.Empty;
authority.ClientSecret = string.IsNullOrWhiteSpace(authority.ClientSecret) ? null : authority.ClientSecret.Trim();
authority.Username = authority.Username?.Trim() ?? string.Empty;
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.ConcelierJobsTrigger : authority.Scope.Trim();
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.ConcelierJobsTrigger : authority.Scope.Trim();
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
authority.BackfillReason = authority.BackfillReason?.Trim() ?? string.Empty;
authority.BackfillTicket = authority.BackfillTicket?.Trim() ?? string.Empty;
authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions();
authority.Resilience.RetryDelays ??= new List<TimeSpan>();

View File

@@ -46,11 +46,15 @@ public sealed class StellaOpsCliAuthorityOptions
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string BackfillReason { get; set; } = string.Empty;
public string BackfillTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
}

View File

@@ -31,6 +31,8 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
@@ -1745,26 +1747,52 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
private IReadOnlyDictionary<string, string>? ResolveOperatorMetadataIfNeeded(string? scope)
private IReadOnlyDictionary<string, string>? ResolveOrchestratorMetadataIfNeeded(string? scope)
{
if (string.IsNullOrWhiteSpace(scope) || !scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
var reason = _options.Authority.OperatorReason?.Trim();
var ticket = _options.Authority.OperatorTicket?.Trim();
var requiresOperate = scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase);
var requiresBackfill = scope.Contains("orch:backfill", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(reason) || string.IsNullOrWhiteSpace(ticket))
if (!requiresOperate && !requiresBackfill)
{
throw new InvalidOperationException("Authority.OperatorReason and Authority.OperatorTicket must be configured when requesting orch:operate tokens. Set STELLAOPS_ORCH_REASON and STELLAOPS_ORCH_TICKET or the corresponding configuration values.");
return null;
}
return new Dictionary<string, string>(StringComparer.Ordinal)
var metadata = new Dictionary<string, string>(StringComparer.Ordinal);
if (requiresOperate)
{
[OperatorReasonParameterName] = reason,
[OperatorTicketParameterName] = ticket
};
var reason = _options.Authority.OperatorReason?.Trim();
var ticket = _options.Authority.OperatorTicket?.Trim();
if (string.IsNullOrWhiteSpace(reason) || string.IsNullOrWhiteSpace(ticket))
{
throw new InvalidOperationException("Authority.OperatorReason and Authority.OperatorTicket must be configured when requesting orch:operate tokens. Set STELLAOPS_ORCH_REASON and STELLAOPS_ORCH_TICKET or the corresponding configuration values.");
}
metadata[OperatorReasonParameterName] = reason;
metadata[OperatorTicketParameterName] = ticket;
}
if (requiresBackfill)
{
var reason = _options.Authority.BackfillReason?.Trim();
var ticket = _options.Authority.BackfillTicket?.Trim();
if (string.IsNullOrWhiteSpace(reason) || string.IsNullOrWhiteSpace(ticket))
{
throw new InvalidOperationException("Authority.BackfillReason and Authority.BackfillTicket must be configured when requesting orch:backfill tokens. Set STELLAOPS_ORCH_BACKFILL_REASON and STELLAOPS_ORCH_BACKFILL_TICKET or the corresponding configuration values.");
}
metadata[BackfillReasonParameterName] = reason;
metadata[BackfillTicketParameterName] = ticket;
}
return metadata;
}
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
@@ -1802,7 +1830,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
var scope = AuthorityTokenUtilities.ResolveScope(_options);
var operatorMetadata = ResolveOperatorMetadataIfNeeded(scope);
var orchestratorMetadata = ResolveOrchestratorMetadataIfNeeded(scope);
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(_options.Authority.Username))
@@ -1821,7 +1849,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
else
{
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, operatorMetadata, cancellationToken).ConfigureAwait(false);
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, orchestratorMetadata, cancellationToken).ConfigureAwait(false);
}
await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,86 @@
using System;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cli.Configuration;
using Xunit;
namespace StellaOps.Cli.Tests.Configuration;
public static class AuthorityTokenUtilitiesTests
{
[Fact]
public static void BuildCacheKey_AppendsOperateMetadataHashes_WhenScopeRequiresOperate()
{
var options = new StellaOpsCliOptions
{
Authority = new StellaOpsCliAuthorityOptions
{
Url = "https://authority.example",
ClientId = "cli",
Scope = "orch:operate",
OperatorReason = "Resume service",
OperatorTicket = "INC-2001"
}
};
var key = AuthorityTokenUtilities.BuildCacheKey(options);
var expectedReasonHash = ComputeHash("Resume service");
var expectedTicketHash = ComputeHash("INC-2001");
Assert.Contains($"|op_reason:{expectedReasonHash}|op_ticket:{expectedTicketHash}", key, StringComparison.Ordinal);
}
[Fact]
public static void BuildCacheKey_AppendsBackfillMetadataHashes_WhenScopeRequiresBackfill()
{
var options = new StellaOpsCliOptions
{
Authority = new StellaOpsCliAuthorityOptions
{
Url = "https://authority.example",
ClientId = "cli",
Scope = "orch:backfill",
BackfillReason = "Rebuild historical findings",
BackfillTicket = "INC-3003"
}
};
var key = AuthorityTokenUtilities.BuildCacheKey(options);
var expectedReasonHash = ComputeHash("Rebuild historical findings");
var expectedTicketHash = ComputeHash("INC-3003");
Assert.Contains($"|bf_reason:{expectedReasonHash}|bf_ticket:{expectedTicketHash}", key, StringComparison.Ordinal);
}
[Fact]
public static void BuildCacheKey_AppendsBothOperateAndBackfillHashes_WhenScopeRequiresBoth()
{
var options = new StellaOpsCliOptions
{
Authority = new StellaOpsCliAuthorityOptions
{
Url = "https://authority.example",
ClientId = "cli",
Scope = "orch:operate orch:backfill",
OperatorReason = "Adjust schedules",
OperatorTicket = "INC-4004",
BackfillReason = "Historical rebuild",
BackfillTicket = "INC-5005"
}
};
var key = AuthorityTokenUtilities.BuildCacheKey(options);
Assert.Contains($"|op_reason:{ComputeHash("Adjust schedules")}|op_ticket:{ComputeHash("INC-4004")}", key, StringComparison.Ordinal);
Assert.Contains($"|bf_reason:{ComputeHash("Historical rebuild")}|bf_ticket:{ComputeHash("INC-5005")}", key, StringComparison.Ordinal);
}
private static string ComputeHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value.Trim());
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -422,11 +422,11 @@ public sealed class BackendOperationsClientTests
}
[Fact]
public async Task TriggerJobAsync_UsesAuthorityTokenWhenConfigured()
{
using var temp = new TempDirectory();
var handler = new StubHttpMessageHandler((request, _) =>
public async Task TriggerJobAsync_UsesAuthorityTokenWhenConfigured()
{
using var temp = new TempDirectory();
var handler = new StubHttpMessageHandler((request, _) =>
{
Assert.NotNull(request.Headers.Authorization);
Assert.Equal("Bearer", request.Headers.Authorization!.Scheme);
@@ -471,10 +471,116 @@ public sealed class BackendOperationsClientTests
var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("Accepted", result.Message);
Assert.True(tokenClient.Requests > 0);
}
Assert.Equal("Accepted", result.Message);
Assert.True(tokenClient.Requests > 0);
}
[Fact]
public async Task TriggerJobAsync_ThrowsWhenBackfillMetadataMissing()
{
using var temp = new TempDirectory();
var handler = new StubHttpMessageHandler((request, _) =>
{
return new HttpResponseMessage(HttpStatusCode.Accepted)
{
RequestMessage = request,
Content = JsonContent.Create(new JobRunResponse
{
RunId = Guid.NewGuid(),
Kind = "test",
Status = "Pending",
Trigger = "cli",
CreatedAt = DateTimeOffset.UtcNow
})
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://concelier.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://concelier.example",
Authority =
{
Url = "https://authority.example",
ClientId = "cli",
ClientSecret = "secret",
Scope = "orch:backfill",
TokenCacheDirectory = temp.Path
}
};
var tokenClient = new StubTokenClient();
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>(), tokenClient);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None));
Assert.Contains("Authority.BackfillReason", exception.Message, StringComparison.Ordinal);
Assert.Equal(0, tokenClient.Requests);
}
[Fact]
public async Task TriggerJobAsync_RequestsOperateAndBackfillMetadata()
{
using var temp = new TempDirectory();
var handler = new StubHttpMessageHandler((request, _) =>
{
Assert.NotNull(request.Headers.Authorization);
return new HttpResponseMessage(HttpStatusCode.Accepted)
{
RequestMessage = request,
Content = JsonContent.Create(new JobRunResponse
{
RunId = Guid.NewGuid(),
Kind = "test",
Status = "Pending",
Trigger = "cli",
CreatedAt = DateTimeOffset.UtcNow
})
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://concelier.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://concelier.example",
Authority =
{
Url = "https://authority.example",
ClientId = "cli",
ClientSecret = "secret",
Scope = "orch:operate orch:backfill",
TokenCacheDirectory = temp.Path,
OperatorReason = "Resume operations",
OperatorTicket = "INC-6006",
BackfillReason = "Historical rebuild",
BackfillTicket = "INC-7007"
}
};
var tokenClient = new StubTokenClient();
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>(), tokenClient);
var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None);
Assert.True(result.Success);
var metadata = Assert.NotNull(tokenClient.LastAdditionalParameters);
Assert.Equal("Resume operations", metadata["operator_reason"]);
Assert.Equal("INC-6006", metadata["operator_ticket"]);
Assert.Equal("Historical rebuild", metadata["backfill_reason"]);
Assert.Equal("INC-7007", metadata["backfill_ticket"]);
}
[Fact]
public async Task EvaluateRuntimePolicyAsync_ParsesDecisionPayload()
{

View File

@@ -29,7 +29,15 @@ public sealed class EntryTraceRuntimeReconciler
"bash",
"dash",
"ash",
"env"
"env",
"bundle",
"docker-php-entrypoint",
"npm",
"npx",
"yarn",
"yarnpkg",
"pipenv",
"poetry"
};
public EntryTraceGraph Reconcile(EntryTraceGraph graph, ProcGraph? procGraph)

View File

@@ -7,8 +7,8 @@
| SCANNER-ENTRYTRACE-18-504 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
| SCANNER-ENTRYTRACE-18-505 | DONE (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-504 | Implement process-tree replay (ProcGraph) to reconcile `/proc` exec chains with static EntryTrace results, collapsing wrappers (tini/gosu/supervisord) and emitting agreement/conflict diagnostics. | Runtime harness walks `/proc` (tests + fixture containers), merges ProcGraph with static graph, records High/Medium/Low confidence outcomes, and adds coverage to integration tests. |
| SCANNER-ENTRYTRACE-18-506 | DONE (2025-11-02) | EntryTrace Guild, Scanner WebService Guild | SCANNER-ENTRYTRACE-18-505 | Surface EntryTrace graph + confidence via Scanner.WebService and CLI (REST + streaming), including target summary in scan reports and policy payloads. | WebService exposes `/scans/{id}/entrytrace` + CLI verb, responses include chain/terminal/confidence/evidence, golden fixtures updated, and Policy/Export contracts documented. |
| SCANNER-ENTRYTRACE-18-507 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Expand candidate discovery beyond ENTRYPOINT/CMD by scanning Docker history metadata and default service directories (`/etc/services/**`, `/s6/**`, `/etc/supervisor/*.conf`, `/usr/local/bin/*-entrypoint`) when explicit commands are absent. | Analyzer produces deterministic fallback candidates with evidence per discovery source, golden fixtures cover supervisor/service directories, and diagnostics distinguish inferred vs declared entrypoints. |
| SCANNER-ENTRYTRACE-18-508 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Extend wrapper catalogue to collapse language/package launchers (`bundle`, `bundle exec`, `docker-php-entrypoint`, `npm`, `yarn node`, `pipenv`, `poetry run`) and vendor init scripts before terminal classification. | Wrapper detection table includes the new aliases with metadata, analyzer unwraps them into underlying commands, and fixture scripts assert metadata for runtime/package managers. |
| SCANNER-ENTRYTRACE-18-507 | DONE (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Expand candidate discovery beyond ENTRYPOINT/CMD by scanning Docker history metadata and default service directories (`/etc/services/**`, `/s6/**`, `/etc/supervisor/*.conf`, `/usr/local/bin/*-entrypoint`) when explicit commands are absent. | Analyzer produces deterministic fallback candidates with evidence per discovery source, golden fixtures cover supervisor/service directories, and diagnostics distinguish inferred vs declared entrypoints. |
| SCANNER-ENTRYTRACE-18-508 | DONE (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Extend wrapper catalogue to collapse language/package launchers (`bundle`, `bundle exec`, `docker-php-entrypoint`, `npm`, `yarn node`, `pipenv`, `poetry run`) and vendor init scripts before terminal classification. | Wrapper detection table includes the new aliases with metadata, analyzer unwraps them into underlying commands, and fixture scripts assert metadata for runtime/package managers. |
| SCANNER-ENTRYTRACE-18-509 | DONE (2025-11-02) | EntryTrace Guild, QA Guild | SCANNER-ENTRYTRACE-18-506 | Add regression coverage for persisted EntryTrace surfaces (result store, WebService endpoint, CLI renderer) and NDJSON payload hashing. | Unit/integration tests cover result retrieval (store/WebService), CLI rendering (`scan entrytrace`), and NDJSON hash stability with fixture snapshots. |
| ENTRYTRACE-SURFACE-01 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-VAL-02, SURFACE-FS-02 | Run Surface.Validation prereq checks and resolve cached entry fragments via Surface.FS to avoid duplicate parsing. | EntryTrace performance metrics show reuse; regression tests updated; validation errors surfaced consistently. |
| ENTRYTRACE-SURFACE-02 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-SECRETS-02 | Replace direct env/secret access with Surface.Secrets provider when tracing runtime configs. | Shared provider used; failure modes covered; documentation refreshed. |

View File

@@ -20,6 +20,239 @@ public sealed class EntryTraceAnalyzerTests
_output = output;
}
[Fact]
public async Task ResolveAsync_CollapsesBundleExecWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/bundle", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/local/bin/puma", CreateGoBinary(), executable: true);
fs.AddFile("/config.rb", "port 3000\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin", "/usr/local/bin"),
"/",
"app",
"sha256:bundle-image",
"scan-bundle",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "bundle", "exec", "puma", "-C", "config.rb" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/local/bin/puma", terminal.Path);
var bundleNode = Assert.Single(result.Nodes.Where(n => n.DisplayName == "bundle"));
Assert.Equal("language-launcher", bundleNode.Metadata?["wrapper.category"]);
Assert.Equal("bundle exec", bundleNode.Metadata?["wrapper.name"]);
Assert.Contains(result.Edges, edge => edge.Relationship == "wrapper" && edge.FromNodeId == bundleNode.Id);
}
[Fact]
public async Task ResolveAsync_CollapsesDockerPhpEntrypointWrapper()
{
var fs = new TestRootFileSystem();
fs.AddFile("/usr/local/bin/docker-php-entrypoint", "#!/bin/sh\nexec \"$@\"\n", executable: true);
fs.AddBinaryFile("/usr/local/sbin/php-fpm", CreateGoBinary(), executable: true);
fs.AddFile("/usr/local/etc/php-fpm.conf", "include=/etc/php-fpm.d/*.conf\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/local/bin", "/usr/local/sbin"),
"/app",
"www-data",
"sha256:php-image",
"scan-php",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "/usr/local/bin/docker-php-entrypoint", "php-fpm", "-y", "/usr/local/etc/php-fpm.conf" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/local/sbin/php-fpm", terminal.Path);
var wrapperNode = Assert.Single(result.Nodes.Where(n => n.DisplayName.Contains("docker-php-entrypoint", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("language-launcher", wrapperNode.Metadata?["wrapper.category"]);
Assert.Equal("docker-php-entrypoint", wrapperNode.Metadata?["wrapper.name"]);
}
[Fact]
public async Task ResolveAsync_CollapsesNpmExecWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/npm", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/node", CreateGoBinary(), executable: true);
fs.AddFile("/server.js", "console.log('hello');", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin"),
"/srv",
"node",
"sha256:npm-image",
"scan-npm",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "npm", "exec", "--yes", "node", "server.js" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.True(
result.Diagnostics.All(d => d.Severity != EntryTraceDiagnosticSeverity.Warning),
string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")));
if (result.Outcome != EntryTraceOutcome.Resolved)
{
throw new XunitException("Outcome: " + result.Outcome + " Diagnostics: " + string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")));
}
if (result.Terminals.Length == 0)
{
throw new XunitException("Terminals missing; diagnostics=" + string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")) +
"; nodes=" + string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}")));
}
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/node", terminal.Path);
var npmNode = Assert.Single(result.Nodes.Where(n => n.DisplayName.Contains("npm", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("language-launcher", npmNode.Metadata?["wrapper.category"]);
Assert.Equal("npm", npmNode.Metadata?["wrapper.name"]);
}
[Fact]
public async Task ResolveAsync_CollapsesYarnNodeWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/local/bin/yarn", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/node", CreateGoBinary(), executable: true);
fs.AddFile("/app.js", "console.log('app');", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/local/bin", "/usr/bin"),
"/workspace",
"node",
"sha256:yarn-image",
"scan-yarn",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "/usr/local/bin/yarn", "node", "app.js" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
if (result.Terminals.Length == 0)
{
throw new XunitException("Terminals missing; diagnostics=" + string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")) +
"; nodes=" + string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}")));
}
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/node", terminal.Path);
var yarnNode = Assert.Single(result.Nodes.Where(n => n.DisplayName.Contains("yarn", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("language-launcher", yarnNode.Metadata?["wrapper.category"]);
Assert.Equal("yarn node", yarnNode.Metadata?["wrapper.name"]);
}
[Fact]
public async Task ResolveAsync_CollapsesPipenvRunWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/local/bin/pipenv", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/app.py", "print('ok')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/local/bin", "/usr/bin"),
"/service",
"python",
"sha256:pipenv-image",
"scan-pipenv",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "pipenv", "run", "python", "app.py" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
if (result.Terminals.Length == 0)
{
throw new XunitException("Terminals missing; diagnostics=" + string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")) +
"; nodes=" + string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}")));
}
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
var pipenvNode = Assert.Single(result.Nodes.Where(n => n.DisplayName.Contains("pipenv", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("language-launcher", pipenvNode.Metadata?["wrapper.category"]);
Assert.Equal("pipenv run", pipenvNode.Metadata?["wrapper.name"]);
}
[Fact]
public async Task ResolveAsync_CollapsesPoetryRunWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/local/bin/poetry", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/manage.py", "print('manage')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/local/bin", "/usr/bin"),
"/srv/app",
"python",
"sha256:poetry-image",
"scan-poetry",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "poetry", "run", "python", "manage.py" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
if (result.Terminals.Length == 0)
{
throw new XunitException("Terminals missing; diagnostics=" + string.Join(", ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Reason}:{d.Message}")) +
"; nodes=" + string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}")));
}
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
var poetryNode = Assert.Single(result.Nodes.Where(n => n.DisplayName.Contains("poetry", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("language-launcher", poetryNode.Metadata?["wrapper.category"]);
Assert.Equal("poetry run", poetryNode.Metadata?["wrapper.name"]);
}
private static EntryTraceAnalyzer CreateAnalyzer()
{
var options = Options.Create(new EntryTraceAnalyzerOptions
@@ -269,6 +502,7 @@ public sealed class EntryTraceAnalyzerTests
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/node", CreateGoBinary(), executable: true);
fs.AddFile("/app/server.js", "console.log('server');\n", executable: false);
var config = new OciImageConfig
{
@@ -284,6 +518,11 @@ public sealed class EntryTraceAnalyzerTests
"scan-history",
NullLogger.Instance);
var candidates = imageContext.Context.Candidates;
var historyCandidate = Assert.Single(candidates);
Assert.Equal("history", historyCandidate.Source);
Assert.True(historyCandidate.Command.SequenceEqual(new[] { "/usr/bin/node", "/app/server.js" }));
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
@@ -291,7 +530,7 @@ public sealed class EntryTraceAnalyzerTests
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromHistory);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/node", terminal.Path);
Assert.Contains("/usr/bin/node", terminal.Arguments[0]);
Assert.Contains("/app/server.js", terminal.Arguments);
}
[Fact]
@@ -315,6 +554,11 @@ public sealed class EntryTraceAnalyzerTests
"scan-supervisor",
NullLogger.Instance);
var candidates = imageContext.Context.Candidates;
var supervisorCandidate = Assert.Single(candidates);
Assert.Equal("supervisor", supervisorCandidate.Source);
Assert.True(supervisorCandidate.Command.SequenceEqual(new[] { "gunicorn", "app:app" }));
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
@@ -322,7 +566,43 @@ public sealed class EntryTraceAnalyzerTests
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromSupervisor);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/gunicorn", terminal.Path);
Assert.Contains("gunicorn", terminal.Arguments[0]);
Assert.Contains("app:app", terminal.Arguments);
}
[Fact]
public async Task ResolveAsync_DiscoversEntrypointScriptCandidate()
{
var fs = new TestRootFileSystem();
fs.AddFile("/usr/local/bin/image-entrypoint", """
#!/bin/sh
exec python /srv/service.py
""", executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/srv/service.py", "print('run')\n", executable: false);
var config = new OciImageConfig();
var options = new EntryTraceAnalyzerOptions();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:image-entrypoint",
"scan-entrypoint",
NullLogger.Instance);
var candidates = imageContext.Context.Candidates;
var scriptCandidate = Assert.Single(candidates);
Assert.Equal("entrypoint-script", scriptCandidate.Source);
Assert.True(scriptCandidate.Command.SequenceEqual(new[] { "/usr/local/bin/image-entrypoint" }));
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromEntrypointScript);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
Assert.Contains("/srv/service.py", terminal.Arguments);
}
[Fact]

View File

@@ -1,64 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Mongo2Go;
namespace StellaOps.Signals.Tests.TestInfrastructure;
internal sealed class SignalsTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
private readonly string storagePath;
public SignalsTestFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
storagePath = Path.Combine(Path.GetTempPath(), "signals-tests", Guid.NewGuid().ToString());
Directory.CreateDirectory(storagePath);
}
public string StoragePath => storagePath;
public MongoDbRunner MongoRunner => mongoRunner;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configuration) =>
{
var settings = new Dictionary<string, string?>
{
["Signals:Authority:Enabled"] = "false",
["Signals:Authority:AllowAnonymousFallback"] = "true",
["Signals:Mongo:ConnectionString"] = mongoRunner.ConnectionString,
["Signals:Mongo:Database"] = "signals-tests",
["Signals:Mongo:CallgraphsCollection"] = "callgraphs",
["Signals:Storage:RootPath"] = storagePath
};
configuration.AddInMemoryCollection(settings);
});
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await Task.Run(() => mongoRunner.Dispose());
try
{
if (Directory.Exists(storagePath))
{
Directory.Delete(storagePath, recursive: true);
}
}
catch
{
// best effort cleanup.
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Mongo2Go;
namespace StellaOps.Signals.Tests.TestInfrastructure;
internal sealed class SignalsTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
private readonly string storagePath;
public SignalsTestFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
storagePath = Path.Combine(Path.GetTempPath(), "signals-tests", Guid.NewGuid().ToString());
Directory.CreateDirectory(storagePath);
}
public string StoragePath => storagePath;
public MongoDbRunner MongoRunner => mongoRunner;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configuration) =>
{
var settings = new Dictionary<string, string?>
{
["Signals:Authority:Enabled"] = "false",
["Signals:Authority:AllowAnonymousFallback"] = "true",
["Signals:Mongo:ConnectionString"] = mongoRunner.ConnectionString,
["Signals:Mongo:Database"] = "signals-tests",
["Signals:Mongo:CallgraphsCollection"] = "callgraphs",
["Signals:Storage:RootPath"] = storagePath
};
configuration.AddInMemoryCollection(settings);
});
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await Task.Run(() => mongoRunner.Dispose());
try
{
if (Directory.Exists(storagePath))
{
Directory.Delete(storagePath, recursive: true);
}
}
catch
{
// best effort cleanup.
}
}
}