feat(authority,scanner): IssuerDirectory wiring + scanner score replay persistence
Authority: StellaOpsLocalHostnameExtensions gains additional local aliases for the IssuerDirectory service; new StellaOpsLocalHostnameExtensionsTests cover the alias table. IssuerDirectory.WebService Program.cs wires the IssuerDirectory host against the shared auth integration. Scanner: WebService swaps in-memory score replay tracking for PersistedScoreReplayRepositories (Postgres-backed) in Program.cs. Docs: scanner architecture page updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -212,7 +212,7 @@ Scanner clients tag immutable uploads with `X-RustFS-Immutable: true` and, when
|
|||||||
|
|
||||||
## 4) REST API (Scanner.WebService)
|
## 4) REST API (Scanner.WebService)
|
||||||
|
|
||||||
All under `/api/v1/scanner`. Auth: **OpTok** (DPoP/mTLS); RBAC scopes.
|
All under `/api/v1`. Auth: **OpTok** (DPoP/mTLS); RBAC scopes.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /scans { imageRef|digest, force?:bool } → { scanId }
|
POST /scans { imageRef|digest, force?:bool } → { scanId }
|
||||||
@@ -230,7 +230,17 @@ GET /healthz | /readyz | /metrics
|
|||||||
```
|
```
|
||||||
See docs/modules/scanner/byos-ingestion.md for BYOS workflow, formats, and troubleshooting.
|
See docs/modules/scanner/byos-ingestion.md for BYOS workflow, formats, and troubleshooting.
|
||||||
|
|
||||||
### 4.1 Localization runtime contract (Sprint 20260224_002)
|
### 4.1 Manifest and proof persistence contract
|
||||||
|
|
||||||
|
- Runtime endpoints:
|
||||||
|
- `GET /api/v1/scans/{id}/manifest`
|
||||||
|
- `GET /api/v1/scans/{id}/proofs`
|
||||||
|
- `GET /api/v1/scans/{id}/proofs/{rootHash}`
|
||||||
|
- The live manifest/proof and score-replay retrieval path is backed by PostgreSQL tables `scanner.scan_manifest` and `scanner.proof_bundle`.
|
||||||
|
- `StellaOps.Scanner.WebService` must not bind these runtime paths to `InMemoryScanManifestRepository`, `InMemoryProofBundleRepository`, `TestManifestRepository`, or `TestProofBundleRepository`.
|
||||||
|
- When singleton replay services need manifest/proof access, they must resolve the scoped PostgreSQL repositories through an adapter rather than bypassing persisted storage.
|
||||||
|
|
||||||
|
### 4.2 Localization runtime contract (Sprint 20260224_002)
|
||||||
|
|
||||||
- Scanner.WebService initializes localization via `AddStellaOpsLocalization(...)`, `AddTranslationBundle(...)`, `AddRemoteTranslationBundles()`, `UseStellaOpsLocalization()`, and `LoadTranslationsAsync()`.
|
- Scanner.WebService initializes localization via `AddStellaOpsLocalization(...)`, `AddTranslationBundle(...)`, `AddRemoteTranslationBundles()`, `UseStellaOpsLocalization()`, and `LoadTranslationsAsync()`.
|
||||||
- Locale resolution order is deterministic: `X-Locale` header -> `Accept-Language` header -> configured default locale (`en-US`).
|
- Locale resolution order is deterministic: `X-Locale` header -> `Accept-Language` header -> configured default locale (`en-US`).
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using StellaOps.Auth.ServerIntegration;
|
||||||
|
using StellaOps.TestKit;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||||
|
|
||||||
|
public sealed class StellaOpsLocalHostnameExtensionsTests
|
||||||
|
{
|
||||||
|
[Trait("Category", TestCategories.Unit)]
|
||||||
|
[Fact]
|
||||||
|
public void ResolveConfiguredListenUrls_ReturnsServerUrls_WhenKestrelEndpointsAreNotConfigured()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ASPNETCORE_URLS"] = "http://+:8080; https://+:8443"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var urls = StellaOpsLocalHostnameExtensions.ResolveConfiguredListenUrls(configuration, serverUrlsSetting: null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "http://+:8080", "https://+:8443" }, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", TestCategories.Unit)]
|
||||||
|
[Fact]
|
||||||
|
public void ResolveConfiguredListenUrls_SkipsServerUrls_WhenExplicitKestrelEndpointsExist()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ASPNETCORE_URLS"] = "http://+:8080",
|
||||||
|
["Kestrel:Endpoints:Http:Url"] = "http://+:8080"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var urls = StellaOpsLocalHostnameExtensions.ResolveConfiguredListenUrls(configuration, serverUrlsSetting: "http://+:8080");
|
||||||
|
|
||||||
|
Assert.Empty(urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
|||||||
|
|
||||||
| Task ID | Status | Notes |
|
| Task ID | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| BOOTSTRAP-006-T | DONE | Sprint `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: added regression coverage for local-binding URL suppression when explicit `Kestrel:Endpoints` exist. |
|
||||||
| AUDIT-0084-M | DONE | Revalidated 2026-01-06. |
|
| AUDIT-0084-M | DONE | Revalidated 2026-01-06. |
|
||||||
| AUDIT-0084-T | DONE | Revalidated 2026-01-06 (coverage updated). |
|
| AUDIT-0084-T | DONE | Revalidated 2026-01-06 (coverage updated). |
|
||||||
| AUDIT-0084-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
| AUDIT-0084-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -104,7 +105,9 @@ public static class StellaOpsLocalHostnameExtensions
|
|||||||
//
|
//
|
||||||
// When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls.
|
// When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls.
|
||||||
// So we must also re-add the dev-port bindings from launchSettings.json.
|
// So we must also re-add the dev-port bindings from launchSettings.json.
|
||||||
var currentUrls = ResolveConfiguredUrls(builder);
|
var currentUrls = ResolveConfiguredListenUrls(
|
||||||
|
builder.Configuration,
|
||||||
|
builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey));
|
||||||
builder.WebHost.ConfigureKestrel((context, kestrel) =>
|
builder.WebHost.ConfigureKestrel((context, kestrel) =>
|
||||||
{
|
{
|
||||||
// Load the configured default certificate (if any) so programmatic
|
// Load the configured default certificate (if any) so programmatic
|
||||||
@@ -119,9 +122,9 @@ public static class StellaOpsLocalHostnameExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS
|
// Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS
|
||||||
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
foreach (var rawUrl in currentUrls)
|
||||||
{
|
{
|
||||||
var normalizedUrl = NormalizeListenUrl(rawUrl.Trim());
|
var normalizedUrl = NormalizeListenUrl(rawUrl);
|
||||||
if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri))
|
if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri))
|
||||||
{
|
{
|
||||||
if (!TryResolveListenAddress(uri.Host, out var addr))
|
if (!TryResolveListenAddress(uri.Host, out var addr))
|
||||||
@@ -266,13 +269,31 @@ public static class StellaOpsLocalHostnameExtensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveConfiguredUrls(WebApplicationBuilder builder)
|
internal static IReadOnlyList<string> ResolveConfiguredListenUrls(
|
||||||
|
IConfiguration configuration,
|
||||||
|
string? serverUrlsSetting)
|
||||||
{
|
{
|
||||||
return builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey)
|
if (HasExplicitKestrelEndpoints(configuration))
|
||||||
?? builder.Configuration[WebHostDefaults.ServerUrlsKey]
|
{
|
||||||
?? builder.Configuration["ASPNETCORE_URLS"]
|
return Array.Empty<string>();
|
||||||
?? builder.Configuration["URLS"]
|
}
|
||||||
|
|
||||||
|
var rawUrls = serverUrlsSetting
|
||||||
|
?? configuration[WebHostDefaults.ServerUrlsKey]
|
||||||
|
?? configuration["ASPNETCORE_URLS"]
|
||||||
|
?? configuration["URLS"]
|
||||||
?? string.Empty;
|
?? string.Empty;
|
||||||
|
|
||||||
|
return rawUrls
|
||||||
|
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool HasExplicitKestrelEndpoints(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
return configuration
|
||||||
|
.GetSection("Kestrel:Endpoints")
|
||||||
|
.GetChildren()
|
||||||
|
.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryResolveListenAddress(string host, out IPAddress address)
|
private static bool TryResolveListenAddress(string host, out IPAddress address)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
|||||||
|
|
||||||
| Task ID | Status | Notes |
|
| Task ID | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| BOOTSTRAP-006 | DONE | Sprint `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: fixed local-binding duplicate port registration when a service also declares `Kestrel:Endpoints`. |
|
||||||
| U-002-AUTH-POLICY | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: remove hard auth-scheme binding that caused console-admin policy endpoints to throw when bearer scheme is not explicitly registered. |
|
| U-002-AUTH-POLICY | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: remove hard auth-scheme binding that caused console-admin policy endpoints to throw when bearer scheme is not explicitly registered. |
|
||||||
| AUDIT-0083-M | DONE | Revalidated 2026-01-06. |
|
| AUDIT-0083-M | DONE | Revalidated 2026-01-06. |
|
||||||
| AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). |
|
| AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using StellaOps.Localization;
|
|||||||
using StellaOps.Auth.ServerIntegration;
|
using StellaOps.Auth.ServerIntegration;
|
||||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||||
using StellaOps.Configuration;
|
using StellaOps.Configuration;
|
||||||
|
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||||
using StellaOps.Infrastructure.Postgres.Options;
|
using StellaOps.Infrastructure.Postgres.Options;
|
||||||
using StellaOps.IssuerDirectory.Core.Services;
|
using StellaOps.IssuerDirectory.Core.Services;
|
||||||
using StellaOps.IssuerDirectory.Infrastructure;
|
using StellaOps.IssuerDirectory.Infrastructure;
|
||||||
@@ -160,6 +161,11 @@ static void ConfigurePersistence(
|
|||||||
opts.ConnectionString = options.Persistence.PostgresConnectionString;
|
opts.ConnectionString = options.Persistence.PostgresConnectionString;
|
||||||
opts.SchemaName = "issuer";
|
opts.SchemaName = "issuer";
|
||||||
});
|
});
|
||||||
|
builder.Services.AddStartupMigrations<IssuerDirectoryWebServiceOptions>(
|
||||||
|
schemaName: IssuerDirectoryDataSource.DefaultSchemaName,
|
||||||
|
moduleName: "IssuerDirectory.Persistence",
|
||||||
|
migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly,
|
||||||
|
connectionStringSelector: static configured => configured.Persistence.PostgresConnectionString);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||||
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
|
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
|
||||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
<ProjectReference Include="..\..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
<ProjectReference Include="..\..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ builder.Services.AddSingleton<PolicySnapshotStore>();
|
|||||||
builder.Services.AddSingleton<PolicyPreviewService>();
|
builder.Services.AddSingleton<PolicyPreviewService>();
|
||||||
builder.Services.AddSingleton<IRecordModeService, RecordModeService>();
|
builder.Services.AddSingleton<IRecordModeService, RecordModeService>();
|
||||||
builder.Services.AddSingleton<IScoreReplayService, ScoreReplayService>();
|
builder.Services.AddSingleton<IScoreReplayService, ScoreReplayService>();
|
||||||
builder.Services.AddSingleton<IScanManifestRepository, InMemoryScanManifestRepository>();
|
builder.Services.AddSingleton<IScanManifestRepository, PersistedScanManifestRepository>();
|
||||||
builder.Services.AddSingleton<IProofBundleRepository, InMemoryProofBundleRepository>();
|
builder.Services.AddSingleton<IProofBundleRepository, PersistedProofBundleRepository>();
|
||||||
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
|
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
|
||||||
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
|
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
|
||||||
builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
|
builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
|
||||||
@@ -252,10 +252,6 @@ builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
|
|||||||
builder.Services.AddVerdictExplainability();
|
builder.Services.AddVerdictExplainability();
|
||||||
builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>();
|
builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>();
|
||||||
|
|
||||||
// Register Storage.Repositories implementations for ManifestEndpoints
|
|
||||||
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, TestManifestRepository>();
|
|
||||||
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, TestProofBundleRepository>();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IProofBundleWriter>(sp =>
|
builder.Services.AddSingleton<IProofBundleWriter>(sp =>
|
||||||
{
|
{
|
||||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||||
@@ -432,6 +428,8 @@ builder.Services.AddScannerStorage(storageOptions =>
|
|||||||
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
|
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
builder.Services.AddScoped<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, PostgresScanManifestRepository>();
|
||||||
|
builder.Services.AddScoped<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, PostgresProofBundleRepository>();
|
||||||
builder.Services.AddOptions<PostgresOptions>()
|
builder.Services.AddOptions<PostgresOptions>()
|
||||||
.Configure(options =>
|
.Configure(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Scanner.Core;
|
||||||
|
using StellaOps.Scanner.Storage;
|
||||||
|
using StellaOps.Scanner.Storage.Entities;
|
||||||
|
using StellaOps.Scanner.Storage.EfCore.Context;
|
||||||
|
using StellaOps.Scanner.Storage.Postgres;
|
||||||
|
using StellaOps.Scanner.WebService.Options;
|
||||||
|
using System.Data;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.WebService.Services;
|
||||||
|
|
||||||
|
public sealed class PersistedScanManifestRepository : IScanManifestRepository
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IScanManifestSigner _manifestSigner;
|
||||||
|
private readonly ScannerDataSource _dataSource;
|
||||||
|
|
||||||
|
public PersistedScanManifestRepository(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IScanManifestSigner manifestSigner,
|
||||||
|
ScannerDataSource dataSource)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||||
|
_manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
|
||||||
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SignedScanManifest?> GetManifestAsync(
|
||||||
|
string scanId,
|
||||||
|
string? manifestHash = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId.Trim(), out var scanGuid))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository>();
|
||||||
|
|
||||||
|
ScanManifestRow? row;
|
||||||
|
if (!string.IsNullOrWhiteSpace(manifestHash))
|
||||||
|
{
|
||||||
|
row = await repository.GetByHashAsync(NormalizeDigest(manifestHash)!, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (row is null || row.ScanId != scanGuid)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row = await repository.GetByScanIdAsync(scanGuid, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ScanManifest.FromJson(row.ManifestContent);
|
||||||
|
return await _manifestSigner.SignAsync(manifest, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveManifestAsync(SignedScanManifest manifest, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
if (!Guid.TryParse(manifest.Manifest.ScanId, out var scanGuid))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Scanner manifest scan id '{manifest.Manifest.ScanId}' is not a UUID and cannot be persisted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository>();
|
||||||
|
|
||||||
|
var row = new ScanManifestRow
|
||||||
|
{
|
||||||
|
ScanId = scanGuid,
|
||||||
|
ManifestHash = NormalizeDigest(manifest.ManifestHash)!,
|
||||||
|
SbomHash = NormalizeDigest(manifest.Manifest.EvidenceDigests?.SbomDigest ?? manifest.Manifest.ArtifactDigest)!,
|
||||||
|
// `rules_hash` is a legacy column in the persisted replay schema. Until the
|
||||||
|
// schema is widened, keep the additional immutable evaluation input here.
|
||||||
|
RulesHash = NormalizeDigest(manifest.Manifest.ExcititorSnapshotHash)!,
|
||||||
|
FeedHash = NormalizeDigest(manifest.Manifest.ConcelierSnapshotHash)!,
|
||||||
|
PolicyHash = NormalizeDigest(manifest.Manifest.LatticePolicyHash)!,
|
||||||
|
ScanStartedAt = manifest.Manifest.CreatedAtUtc,
|
||||||
|
ScanCompletedAt = null,
|
||||||
|
ManifestContent = manifest.Manifest.ToJson(),
|
||||||
|
ScannerVersion = manifest.Manifest.ScannerVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
await repository.SaveAsync(row, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> FindAffectedScansAsync(AffectedScansQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (query.ChangedFeeds.Count == 0 || query.Limit <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldConcelierHash = query.ChangedFeeds.Contains("concelier", StringComparer.OrdinalIgnoreCase)
|
||||||
|
? NormalizeDigest(query.OldConcelierHash)
|
||||||
|
: null;
|
||||||
|
var oldExcititorHash = query.ChangedFeeds.Contains("excititor", StringComparer.OrdinalIgnoreCase)
|
||||||
|
? NormalizeDigest(query.OldExcititorHash)
|
||||||
|
: null;
|
||||||
|
var oldPolicyHash = query.ChangedFeeds.Contains("policy", StringComparer.OrdinalIgnoreCase)
|
||||||
|
? NormalizeDigest(query.OldPolicyHash)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (oldConcelierHash is null && oldExcititorHash is null && oldPolicyHash is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaName = _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||||
|
|
||||||
|
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
$"""
|
||||||
|
SELECT scan_id::text
|
||||||
|
FROM {schemaName}.scan_manifest
|
||||||
|
WHERE created_at >= @min_created_at
|
||||||
|
AND (
|
||||||
|
(@old_feed_hash IS NOT NULL AND feed_hash = @old_feed_hash)
|
||||||
|
OR (@old_excititor_hash IS NOT NULL AND manifest_content ->> 'excititorSnapshotHash' = @old_excititor_hash)
|
||||||
|
OR (@old_policy_hash IS NOT NULL AND policy_hash = @old_policy_hash)
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC, scan_id ASC
|
||||||
|
LIMIT @limit
|
||||||
|
""";
|
||||||
|
|
||||||
|
AddParameter(command, "@min_created_at", query.MinCreatedAt);
|
||||||
|
AddParameter(command, "@old_feed_hash", oldConcelierHash);
|
||||||
|
AddParameter(command, "@old_excititor_hash", oldExcititorHash);
|
||||||
|
AddParameter(command, "@old_policy_hash", oldPolicyHash);
|
||||||
|
AddParameter(command, "@limit", query.Limit);
|
||||||
|
|
||||||
|
var results = new List<string>(capacity: Math.Min(query.Limit, 256));
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
results.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddParameter(IDbCommand command, string name, object? value)
|
||||||
|
{
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = name;
|
||||||
|
parameter.Value = value ?? DBNull.Value;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeDigest(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmed = $"sha256:{trimmed}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PersistedProofBundleRepository : IProofBundleRepository
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IProofBundleWriter _bundleWriter;
|
||||||
|
private readonly string _bundleStoragePath;
|
||||||
|
|
||||||
|
public PersistedProofBundleRepository(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IProofBundleWriter bundleWriter,
|
||||||
|
IOptions<ScannerWebServiceOptions> options,
|
||||||
|
IHostEnvironment hostEnvironment)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||||
|
_bundleWriter = bundleWriter ?? throw new ArgumentNullException(nameof(bundleWriter));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(hostEnvironment);
|
||||||
|
|
||||||
|
var configuredPath = options.Value.ScoreReplay.BundleStoragePath?.Trim() ?? string.Empty;
|
||||||
|
var defaultPath = hostEnvironment.IsEnvironment("Testing")
|
||||||
|
? Path.Combine(Path.GetTempPath(), "stellaops-proofs-testing")
|
||||||
|
: Path.Combine(Path.GetTempPath(), "stellaops-proofs");
|
||||||
|
_bundleStoragePath = string.IsNullOrWhiteSpace(configuredPath) ? defaultPath : configuredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProofBundle?> GetBundleAsync(string scanId, string? rootHash = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId.Trim(), out var scanGuid))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository>();
|
||||||
|
|
||||||
|
ProofBundleRow? row;
|
||||||
|
if (!string.IsNullOrWhiteSpace(rootHash))
|
||||||
|
{
|
||||||
|
row = await repository.GetByRootHashAsync(NormalizeDigest(rootHash)!, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (row is null || row.ScanId != scanGuid)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row = (await repository.GetByScanIdAsync(scanGuid, cancellationToken).ConfigureAwait(false)).FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundleUri = await EnsureBundleMaterializedAsync(row, cancellationToken).ConfigureAwait(false);
|
||||||
|
return new ProofBundle(row.ScanId.ToString("D"), row.RootHash, bundleUri, row.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveBundleAsync(ProofBundle bundle, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bundle);
|
||||||
|
|
||||||
|
if (!Guid.TryParse(bundle.ScanId, out var scanGuid))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Scanner proof bundle scan id '{bundle.ScanId}' is not a UUID and cannot be persisted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[]? bundleContent = null;
|
||||||
|
string bundleHash;
|
||||||
|
string? manifestHash = null;
|
||||||
|
string? sbomHash = null;
|
||||||
|
string? vexHash = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(bundle.BundleUri) && File.Exists(bundle.BundleUri))
|
||||||
|
{
|
||||||
|
bundleContent = await File.ReadAllBytesAsync(bundle.BundleUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
bundleHash = NormalizeDigest(Convert.ToHexStringLower(SHA256.HashData(bundleContent)))!;
|
||||||
|
|
||||||
|
var contents = await _bundleWriter.ReadBundleAsync(bundle.BundleUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
manifestHash = NormalizeDigest(contents.SignedManifest.ManifestHash);
|
||||||
|
sbomHash = NormalizeDigest(contents.SignedManifest.Manifest.EvidenceDigests?.SbomDigest ?? contents.SignedManifest.Manifest.ArtifactDigest);
|
||||||
|
vexHash = NormalizeDigest(contents.SignedManifest.Manifest.EvidenceDigests?.VexDigest ?? contents.SignedManifest.Manifest.ExcititorSnapshotHash);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bundleHash = NormalizeDigest(bundle.RootHash)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository>();
|
||||||
|
|
||||||
|
var row = new ProofBundleRow
|
||||||
|
{
|
||||||
|
ScanId = scanGuid,
|
||||||
|
RootHash = NormalizeDigest(bundle.RootHash)!,
|
||||||
|
BundleType = "standard",
|
||||||
|
BundleContent = bundleContent,
|
||||||
|
BundleHash = bundleHash,
|
||||||
|
LedgerHash = NormalizeDigest(bundle.RootHash),
|
||||||
|
ManifestHash = manifestHash,
|
||||||
|
SbomHash = sbomHash,
|
||||||
|
VexHash = vexHash,
|
||||||
|
CreatedAt = bundle.CreatedAtUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
await repository.SaveAsync(row, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> EnsureBundleMaterializedAsync(ProofBundleRow row, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_bundleStoragePath);
|
||||||
|
|
||||||
|
var normalizedRootHash = NormalizeDigest(row.RootHash)!;
|
||||||
|
var hashSuffix = normalizedRootHash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? normalizedRootHash["sha256:".Length..]
|
||||||
|
: normalizedRootHash;
|
||||||
|
var fileName = $"{row.ScanId:D}_{hashSuffix[..Math.Min(hashSuffix.Length, 16)]}.zip";
|
||||||
|
var bundlePath = Path.Combine(_bundleStoragePath, fileName);
|
||||||
|
|
||||||
|
if (!File.Exists(bundlePath) && row.BundleContent is not null)
|
||||||
|
{
|
||||||
|
await File.WriteAllBytesAsync(bundlePath, row.BundleContent, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundlePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeDigest(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmed = $"sha256:{trimmed}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
|||||||
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). |
|
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). |
|
||||||
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Scanner WebService and replaced selected hardcoded endpoint strings with `_t(...)` keys (en-US/de-DE bundles added). |
|
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Scanner WebService and replaced selected hardcoded endpoint strings with `_t(...)` keys (en-US/de-DE bundles added). |
|
||||||
| SPRINT-20260311-003-VULNREAD-001 | DONE | `SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md`: restored the documented scanner-backed `/api/v1/vulnerabilities` read contract for the live triage artifact workspace, with targeted controller tests and compose redeploy proof (2026-03-11). |
|
| SPRINT-20260311-003-VULNREAD-001 | DONE | `SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md`: restored the documented scanner-backed `/api/v1/vulnerabilities` read contract for the live triage artifact workspace, with targeted controller tests and compose redeploy proof (2026-03-11). |
|
||||||
|
| NOMOCK-013 | DONE | `SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: removed live in-memory/test manifest and proof runtime bindings from `StellaOps.Scanner.WebService`, replaced them with scoped Postgres adapters for score-replay and manifest retrieval, and verified `GET /api/v1/scans/{id}/manifest` plus `/proofs*` return persisted rows before and after a `scanner-web` recreate (2026-04-14). |
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
|||||||
| SCAN-EF-03 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Converted all Dapper repositories to EF Core. Removed Dapper package. Build 0 errors 0 warnings (2026-02-23). |
|
| SCAN-EF-03 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Converted all Dapper repositories to EF Core. Removed Dapper package. Build 0 errors 0 warnings (2026-02-23). |
|
||||||
| SCAN-EF-04 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Compiled model and runtime static model path verified (2026-02-23). |
|
| SCAN-EF-04 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Compiled model and runtime static model path verified (2026-02-23). |
|
||||||
| SCAN-EF-05 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Sequential build validation passed. Sprint docs updated (2026-02-23). |
|
| SCAN-EF-05 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Sequential build validation passed. Sprint docs updated (2026-02-23). |
|
||||||
|
| NOMOCK-013 | DONE | `SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: Scanner manifest/proof runtime now serves `scanner.scan_manifest` and `scanner.proof_bundle` through the PostgreSQL repositories used by the live WebService and score-replay adapters, with live API proof across a `scanner-web` recreate (2026-04-14). |
|
||||||
|
|||||||
Reference in New Issue
Block a user