Add Authority Advisory AI and API Lifecycle Configuration

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

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net;
@@ -25,6 +26,7 @@ using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Tests.Testing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Scanner.EntryTrace;
using Spectre.Console;
using Spectre.Console.Testing;
@@ -82,11 +84,11 @@ public sealed class CommandHandlersTests
}
[Fact]
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
{
using var tempDir = new TempDirectory();
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
{
using var tempDir = new TempDirectory();
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
var options = new StellaOpsCliOptions
@@ -117,13 +119,114 @@ public sealed class CommandHandlersTests
finally
{
Environment.ExitCode = original;
}
}
[Fact]
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
{
var original = Environment.ExitCode;
}
}
[Fact]
public async Task HandleScanEntryTraceAsync_RendersPlansAndNdjson()
{
var originalExit = Environment.ExitCode;
var console = new TestConsole();
var originalConsole = AnsiConsole.Console;
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(new EntryTracePlan(
ImmutableArray.Create("/usr/bin/python", "app.py"),
ImmutableDictionary<string, string>.Empty,
"/workspace",
"appuser",
"/usr/bin/python",
EntryTraceTerminalType.Managed,
"python",
0.95,
ImmutableDictionary<string, string>.Empty)),
ImmutableArray.Create(new EntryTraceTerminal(
"/usr/bin/python",
EntryTraceTerminalType.Managed,
"python",
0.95,
ImmutableDictionary<string, string>.Empty,
"appuser",
"/workspace",
ImmutableArray<string>.Empty)));
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null))
{
EntryTraceResponse = new EntryTraceResponseModel(
"scan-123",
"sha256:deadbeef",
DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
graph,
new[] { "{\"type\":\"terminal\"}" })
};
var provider = BuildServiceProvider(backend);
AnsiConsole.Console = console;
try
{
await CommandHandlers.HandleScanEntryTraceAsync(
provider,
"scan-123",
includeNdjson: true,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.Equal("scan-123", backend.LastEntryTraceScanId);
var output = console.Output;
Assert.Contains("scan-123", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("NDJSON Output", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("{\"type\":\"terminal\"}", output, StringComparison.Ordinal);
Assert.Contains("/usr/bin/python", output, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
}
[Fact]
public async Task HandleScanEntryTraceAsync_WarnsWhenResultMissing()
{
var originalExit = Environment.ExitCode;
var console = new TestConsole();
var originalConsole = AnsiConsole.Console;
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
var provider = BuildServiceProvider(backend);
AnsiConsole.Console = console;
try
{
await CommandHandlers.HandleScanEntryTraceAsync(
provider,
"scan-missing",
includeNdjson: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(1, Environment.ExitCode);
Assert.Equal("scan-missing", backend.LastEntryTraceScanId);
Assert.Contains("No EntryTrace data", console.Output, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
}
[Fact]
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
{
var original = Environment.ExitCode;
using var tempDir = new TempDirectory();
try
@@ -2327,13 +2430,16 @@ public sealed class CommandHandlersTests
null);
public (string PolicyId, string FindingId)? LastFindingGet { get; private set; }
public PolicyApiException? FindingGetException { get; set; }
public PolicyFindingExplainResult ExplainResult { get; set; } = new PolicyFindingExplainResult(
"finding-default",
1,
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
new ReadOnlyCollection<PolicyFindingExplainHint>(Array.Empty<PolicyFindingExplainHint>()));
public (string PolicyId, string FindingId, string? Mode)? LastFindingExplain { get; private set; }
public PolicyApiException? FindingExplainException { get; set; }
public PolicyFindingExplainResult ExplainResult { get; set; } = new PolicyFindingExplainResult(
"finding-default",
1,
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
new ReadOnlyCollection<PolicyFindingExplainHint>(Array.Empty<PolicyFindingExplainHint>()));
public (string PolicyId, string FindingId, string? Mode)? LastFindingExplain { get; private set; }
public PolicyApiException? FindingExplainException { get; set; }
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
public Exception? EntryTraceException { get; set; }
public string? LastEntryTraceScanId { get; private set; }
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
=> throw new NotImplementedException();
@@ -2445,27 +2551,37 @@ public sealed class CommandHandlersTests
return Task.FromResult(FindingDocument);
}
public Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken)
{
LastFindingExplain = (policyId, findingId, mode);
if (FindingExplainException is not null)
{
throw FindingExplainException;
}
return Task.FromResult(ExplainResult);
}
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
public Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken)
{
LastFindingExplain = (policyId, findingId, mode);
if (FindingExplainException is not null)
{
throw FindingExplainException;
}
return Task.FromResult(ExplainResult);
}
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
{
LastEntryTraceScanId = scanId;
if (EntryTraceException is not null)
{
throw EntryTraceException;
}
return Task.FromResult(EntryTraceResponse);
}
}
private sealed class StubExecutor : IScannerExecutor
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Net;
@@ -17,9 +18,10 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.Transport;
using StellaOps.Cli.Tests.Testing;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.Transport;
using StellaOps.Cli.Tests.Testing;
using StellaOps.Scanner.EntryTrace;
using System.Linq;
namespace StellaOps.Cli.Tests.Services;
@@ -170,11 +172,11 @@ public sealed class BackendOperationsClientTests
}
[Fact]
public async Task UploadScanResultsAsync_RetriesOnRetryAfter()
{
using var temp = new TempDirectory();
var filePath = Path.Combine(temp.Path, "scan.json");
await File.WriteAllTextAsync(filePath, "{}");
public async Task UploadScanResultsAsync_RetriesOnRetryAfter()
{
using var temp = new TempDirectory();
var filePath = Path.Combine(temp.Path, "scan.json");
await File.WriteAllTextAsync(filePath, "{}");
var attempts = 0;
var handler = new StubHttpMessageHandler(
@@ -250,9 +252,103 @@ public sealed class BackendOperationsClientTests
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None));
Assert.Equal(2, attempts);
}
await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None));
Assert.Equal(2, attempts);
}
[Fact]
public async Task GetEntryTraceAsync_ReturnsResponse()
{
var scanId = $"scan-{Guid.NewGuid():n}";
var generatedAt = new DateTimeOffset(2025, 11, 1, 8, 30, 0, TimeSpan.Zero);
var plan = new EntryTracePlan(
ImmutableArray.Create("/usr/bin/app"),
ImmutableDictionary<string, string>.Empty,
"/work",
"root",
"/usr/bin/app",
EntryTraceTerminalType.Native,
"go",
80d,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/usr/bin/app",
EntryTraceTerminalType.Native,
"go",
80d,
ImmutableDictionary<string, string>.Empty,
"root",
"/work",
ImmutableArray<string>.Empty);
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(plan),
ImmutableArray.Create(terminal));
var responseModel = new EntryTraceResponseModel(
scanId,
"sha256:test",
generatedAt,
graph,
EntryTraceNdjsonWriter.Serialize(graph, new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt)));
var json = JsonSerializer.Serialize(responseModel, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var handler = new StubHttpMessageHandler((request, _) =>
{
var message = new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = request,
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return message;
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://scanner.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://scanner.example"
};
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var result = await client.GetEntryTraceAsync(scanId, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(responseModel.ScanId, result!.ScanId);
Assert.Equal(responseModel.ImageDigest, result.ImageDigest);
Assert.Equal(responseModel.Graph.Plans.Length, result.Graph.Plans.Length);
Assert.Equal(responseModel.Ndjson.Count, result.Ndjson.Count);
}
[Fact]
public async Task GetEntryTraceAsync_ReturnsNullWhenNotFound()
{
var handler = new StubHttpMessageHandler((request, _) => new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://scanner.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://scanner.example"
};
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var result = await client.GetEntryTraceAsync("scan-missing", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task TriggerJobAsync_ReturnsAcceptedResult()
@@ -809,13 +905,13 @@ public sealed class BackendOperationsClientTests
switch (name)
{
case "metadata":
MetadataJson = await part.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
MetadataJson = await part.ReadAsStringAsync(cancellationToken);
break;
case "bundle":
BundlePayload = await part.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
BundlePayload = await part.ReadAsByteArrayAsync(cancellationToken);
break;
case "manifest":
ManifestPayload = await part.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
ManifestPayload = await part.ReadAsByteArrayAsync(cancellationToken);
break;
}
}