Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -19,9 +19,11 @@ using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Attestor.Persistence;
|
||||
using StellaOps.Attestor.ProofChain;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.Watchlist;
|
||||
using StellaOps.Attestor.WebService.Endpoints;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
@@ -141,6 +143,13 @@ internal static class AttestorWebServiceComposition
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddProofChainServices();
|
||||
|
||||
// Predicate type registry (Sprint: SPRINT_20260219_010, PSR-02)
|
||||
var postgresConnectionString = builder.Configuration["attestor:postgres:connectionString"];
|
||||
if (!string.IsNullOrWhiteSpace(postgresConnectionString))
|
||||
{
|
||||
builder.Services.AddPredicateTypeRegistry(postgresConnectionString);
|
||||
}
|
||||
|
||||
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
|
||||
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
|
||||
|
||||
@@ -410,6 +419,7 @@ internal static class AttestorWebServiceComposition
|
||||
app.MapControllers();
|
||||
app.MapAttestorEndpoints(attestorOptions);
|
||||
app.MapWatchlistEndpoints();
|
||||
app.MapPredicateRegistryEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PredicateRegistryEndpoints.cs
|
||||
// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
// Task: PSR-02 - Create Predicate Schema Registry endpoints and repository
|
||||
// Description: REST API endpoints for the predicate type schema registry
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for the predicate type schema registry.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// </summary>
|
||||
public static class PredicateRegistryEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps predicate registry endpoints.
|
||||
/// </summary>
|
||||
public static void MapPredicateRegistryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/attestor/predicates")
|
||||
.WithTags("Predicate Registry")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/", ListPredicateTypes)
|
||||
.WithName("ListPredicateTypes")
|
||||
.WithSummary("List all registered predicate types")
|
||||
.Produces<PredicateTypeListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{uri}", GetPredicateType)
|
||||
.WithName("GetPredicateType")
|
||||
.WithSummary("Get predicate type schema by URI")
|
||||
.Produces<PredicateTypeRegistryEntry>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPredicateTypes(
|
||||
IPredicateTypeRegistryRepository repository,
|
||||
string? category = null,
|
||||
bool? isActive = null,
|
||||
int offset = 0,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await repository.ListAsync(category, isActive, offset, limit, ct);
|
||||
return Results.Ok(new PredicateTypeListResponse
|
||||
{
|
||||
Items = entries,
|
||||
Offset = offset,
|
||||
Limit = limit,
|
||||
Count = entries.Count,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPredicateType(
|
||||
string uri,
|
||||
IPredicateTypeRegistryRepository repository,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var decoded = Uri.UnescapeDataString(uri);
|
||||
var entry = await repository.GetByUriAsync(decoded, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Predicate type not found", uri = decoded });
|
||||
}
|
||||
|
||||
return Results.Ok(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing predicate types.
|
||||
/// </summary>
|
||||
public sealed record PredicateTypeListResponse
|
||||
{
|
||||
/// <summary>The predicate type entries.</summary>
|
||||
public required IReadOnlyList<PredicateTypeRegistryEntry> Items { get; init; }
|
||||
|
||||
/// <summary>Pagination offset.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Pagination limit.</summary>
|
||||
public int Limit { get; init; }
|
||||
|
||||
/// <summary>Number of items returned.</summary>
|
||||
public int Count { get; init; }
|
||||
}
|
||||
@@ -32,5 +32,6 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- Attestor Schema Migration 002: Predicate Type Registry
|
||||
-- Sprint: SPRINT_20260219_010 (PSR-01)
|
||||
-- Creates discoverable, versioned registry for all predicate types
|
||||
|
||||
-- ============================================================================
|
||||
-- Predicate Type Registry Table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proofchain.predicate_type_registry (
|
||||
registry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
predicate_type_uri TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
category TEXT NOT NULL DEFAULT 'stella-core'
|
||||
CHECK (category IN ('stella-core', 'stella-proof', 'stella-delta', 'ecosystem', 'intoto', 'custom')),
|
||||
json_schema JSONB,
|
||||
description TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
validation_mode TEXT NOT NULL DEFAULT 'log-only'
|
||||
CHECK (validation_mode IN ('log-only', 'warn', 'reject')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_predicate_type_version UNIQUE (predicate_type_uri, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_predicate_registry_uri
|
||||
ON proofchain.predicate_type_registry(predicate_type_uri);
|
||||
CREATE INDEX IF NOT EXISTS idx_predicate_registry_category
|
||||
ON proofchain.predicate_type_registry(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_predicate_registry_active
|
||||
ON proofchain.predicate_type_registry(is_active) WHERE is_active = TRUE;
|
||||
|
||||
-- Apply updated_at trigger
|
||||
DROP TRIGGER IF EXISTS update_predicate_registry_updated_at ON proofchain.predicate_type_registry;
|
||||
CREATE TRIGGER update_predicate_registry_updated_at
|
||||
BEFORE UPDATE ON proofchain.predicate_type_registry
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
|
||||
COMMENT ON TABLE proofchain.predicate_type_registry IS 'Discoverable registry of all predicate types accepted by the Attestor';
|
||||
COMMENT ON COLUMN proofchain.predicate_type_registry.predicate_type_uri IS 'Canonical URI for the predicate type (e.g., https://stella-ops.org/predicates/evidence/v1)';
|
||||
COMMENT ON COLUMN proofchain.predicate_type_registry.validation_mode IS 'How mismatches are handled: log-only (default), warn, or reject';
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: stella-core predicates
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO proofchain.predicate_type_registry (predicate_type_uri, display_name, version, category, description) VALUES
|
||||
('https://stella-ops.org/predicates/sbom-linkage/v1', 'SBOM Linkage', '1.0.0', 'stella-core', 'Links SBOM components to evidence and proof spines'),
|
||||
('https://stella-ops.org/predicates/vex-verdict/v1', 'VEX Verdict', '1.0.0', 'stella-core', 'VEX consensus verdict for an artifact+advisory tuple'),
|
||||
('https://stella-ops.org/predicates/evidence/v1', 'Evidence', '1.0.0', 'stella-core', 'Generic evidence attestation linking scan results to artifacts'),
|
||||
('https://stella-ops.org/predicates/reasoning/v1', 'Reasoning', '1.0.0', 'stella-core', 'Policy reasoning chain for a release decision'),
|
||||
('https://stella-ops.org/predicates/proof-spine/v1', 'Proof Spine', '1.0.0', 'stella-core', 'Merkle-aggregated proof spine linking evidence to verdicts'),
|
||||
('https://stella-ops.org/predicates/reachability-drift/v1', 'Reachability Drift', '1.0.0', 'stella-core', 'Reachability state changes between consecutive scans'),
|
||||
('https://stella-ops.org/predicates/reachability-subgraph/v1', 'Reachability Subgraph', '1.0.0', 'stella-core', 'Call graph subgraph for a specific vulnerability path'),
|
||||
('https://stella-ops.org/predicates/delta-verdict/v1', 'Delta Verdict', '1.0.0', 'stella-core', 'Verdict differences between two scan runs'),
|
||||
('https://stella-ops.org/predicates/policy-decision/v1', 'Policy Decision', '1.0.0', 'stella-core', 'Policy engine evaluation result for a release gate'),
|
||||
('https://stella-ops.org/predicates/unknowns-budget/v1', 'Unknowns Budget', '1.0.0', 'stella-core', 'Budget check for unknown reachability components'),
|
||||
('https://stella-ops.org/predicates/ai-code-guard/v1', 'AI Code Guard', '1.0.0', 'stella-core', 'AI-assisted code security analysis results'),
|
||||
('https://stella-ops.org/predicates/fix-chain/v1', 'Fix Chain', '1.0.0', 'stella-core', 'Linked chain of fix commits from vulnerability to resolution'),
|
||||
('https://stella-ops.org/attestation/graph-root/v1', 'Graph Root', '1.0.0', 'stella-core', 'Root attestation for a complete call graph')
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: stella-proof predicates (ProofChain)
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO proofchain.predicate_type_registry (predicate_type_uri, display_name, version, category, description) VALUES
|
||||
('https://stella.ops/predicates/path-witness/v1', 'Path Witness', '1.0.0', 'stella-proof', 'Entrypoint-to-sink call path witness with gate detection'),
|
||||
('https://stella.ops/predicates/runtime-witness/v1', 'Runtime Witness', '1.0.0', 'stella-proof', 'Runtime micro-witness from eBPF/ETW observations'),
|
||||
('https://stella.ops/predicates/policy-decision@v2', 'Policy Decision v2', '2.0.0', 'stella-proof', 'Enhanced policy decision with reachability context'),
|
||||
('https://stellaops.dev/predicates/binary-micro-witness@v1', 'Binary Micro-Witness', '1.0.0', 'stella-proof', 'Binary-level micro-witness with build ID correlation'),
|
||||
('https://stellaops.dev/predicates/binary-fingerprint-evidence@v1', 'Binary Fingerprint', '1.0.0', 'stella-proof', 'Binary fingerprint evidence for patch detection'),
|
||||
('https://stellaops.io/attestation/budget-check/v1', 'Budget Check', '1.0.0', 'stella-proof', 'Unknowns budget check attestation'),
|
||||
('https://stellaops.dev/attestation/vex/v1', 'VEX Attestation', '1.0.0', 'stella-proof', 'DSSE-signed VEX statement attestation'),
|
||||
('https://stellaops.dev/attestations/vex-override/v1', 'VEX Override', '1.0.0', 'stella-proof', 'Manual VEX override decision with justification'),
|
||||
('https://stellaops.dev/predicates/trust-verdict@v1', 'Trust Verdict', '1.0.0', 'stella-proof', 'Trust lattice verdict combining P/C/R vectors'),
|
||||
('https://stellaops.io/attestation/v1/signed-exception', 'Signed Exception', '1.0.0', 'stella-proof', 'Manually approved exception with expiry'),
|
||||
('https://stellaops.dev/attestation/verification-report/v1', 'Verification Report', '1.0.0', 'stella-proof', 'QA verification report attestation')
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: stella-delta predicates
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO proofchain.predicate_type_registry (predicate_type_uri, display_name, version, category, description) VALUES
|
||||
('stella.ops/changetrace@v1', 'Change Trace', '1.0.0', 'stella-delta', 'File-level change trace between SBOM versions'),
|
||||
('stella.ops/vex-delta@v1', 'VEX Delta', '1.0.0', 'stella-delta', 'VEX statement differences between consecutive ingestions'),
|
||||
('stella.ops/sbom-delta@v1', 'SBOM Delta', '1.0.0', 'stella-delta', 'Component differences between two SBOM versions'),
|
||||
('stella.ops/verdict-delta@v1', 'Verdict Delta', '1.0.0', 'stella-delta', 'Verdict changes between policy evaluations'),
|
||||
('stellaops.binarydiff.v1', 'Binary Diff', '1.0.0', 'stella-delta', 'Binary diff signatures for patch detection')
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: ecosystem predicates
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO proofchain.predicate_type_registry (predicate_type_uri, display_name, version, category, description) VALUES
|
||||
('https://spdx.dev/Document', 'SPDX Document', '2.3.0', 'ecosystem', 'SPDX 2.x document attestation'),
|
||||
('https://cyclonedx.org/bom', 'CycloneDX BOM', '1.7.0', 'ecosystem', 'CycloneDX BOM attestation'),
|
||||
('https://slsa.dev/provenance', 'SLSA Provenance', '1.0.0', 'ecosystem', 'SLSA v1.0 build provenance')
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: in-toto standard predicates
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO proofchain.predicate_type_registry (predicate_type_uri, display_name, version, category, description) VALUES
|
||||
('https://in-toto.io/Statement/v1', 'In-Toto Statement', '1.0.0', 'intoto', 'In-toto attestation statement wrapper'),
|
||||
('https://in-toto.io/Link/v1', 'In-Toto Link', '1.0.0', 'intoto', 'In-toto supply chain link'),
|
||||
('https://in-toto.io/Layout/v1', 'In-Toto Layout', '1.0.0', 'intoto', 'In-toto supply chain layout')
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration 003: Artifact Canonical Record materialized view
|
||||
-- Sprint: SPRINT_20260219_009 (CID-04)
|
||||
-- Purpose: Unified read projection joining sbom_entries + dsse_envelopes + rekor_entries
|
||||
-- for the Evidence Thread API (GET /api/v1/evidence/thread/{canonical_id}).
|
||||
|
||||
-- Materialized view: one row per canonical_id with aggregated attestation evidence.
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS proofchain.artifact_canonical_records AS
|
||||
SELECT
|
||||
se.bom_digest AS canonical_id,
|
||||
'cyclonedx-jcs:1'::text AS format,
|
||||
se.artifact_digest,
|
||||
se.purl,
|
||||
se.created_at,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
DISTINCT jsonb_build_object(
|
||||
'predicate_type', de.predicate_type,
|
||||
'dsse_digest', de.body_hash,
|
||||
'signer_keyid', de.signer_keyid,
|
||||
'rekor_entry_id', re.uuid,
|
||||
'rekor_tile', re.log_id,
|
||||
'signed_at', de.signed_at
|
||||
)
|
||||
) FILTER (WHERE de.env_id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS attestations
|
||||
FROM proofchain.sbom_entries se
|
||||
LEFT JOIN proofchain.dsse_envelopes de ON de.entry_id = se.entry_id
|
||||
LEFT JOIN proofchain.rekor_entries re ON re.env_id = de.env_id
|
||||
GROUP BY se.entry_id, se.bom_digest, se.artifact_digest, se.purl, se.created_at;
|
||||
|
||||
-- Unique index for CONCURRENTLY refresh and fast lookup.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_acr_canonical_id
|
||||
ON proofchain.artifact_canonical_records (canonical_id);
|
||||
|
||||
-- Index for PURL-based lookup (Evidence Thread by PURL).
|
||||
CREATE INDEX IF NOT EXISTS idx_acr_purl
|
||||
ON proofchain.artifact_canonical_records (purl)
|
||||
WHERE purl IS NOT NULL;
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW proofchain.artifact_canonical_records IS
|
||||
'Unified read projection for the Evidence Thread API. Joins SBOM entries, DSSE envelopes, and Rekor entries into one row per canonical_id. Refresh via REFRESH MATERIALIZED VIEW CONCURRENTLY.';
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.Persistence.Repositories;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
@@ -28,4 +29,18 @@ public static class PersistenceServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the predicate type registry repository backed by PostgreSQL.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPredicateTypeRegistry(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
services.TryAddSingleton<IPredicateTypeRegistryRepository>(
|
||||
new PostgresPredicateTypeRegistryRepository(connectionString));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IPredicateTypeRegistryRepository.cs
|
||||
// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
// Task: PSR-02 - Create Predicate Schema Registry endpoints and repository
|
||||
// Description: Repository interface for predicate type registry lookups and management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for predicate type registry lookups and management.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// </summary>
|
||||
public interface IPredicateTypeRegistryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists predicate type entries with optional filtering.
|
||||
/// </summary>
|
||||
/// <param name="category">Optional category filter.</param>
|
||||
/// <param name="isActive">Optional active status filter.</param>
|
||||
/// <param name="offset">Pagination offset.</param>
|
||||
/// <param name="limit">Maximum entries to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching entries ordered by category and URI.</returns>
|
||||
Task<IReadOnlyList<PredicateTypeRegistryEntry>> ListAsync(
|
||||
string? category = null,
|
||||
bool? isActive = null,
|
||||
int offset = 0,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a predicate type entry by its URI (latest version).
|
||||
/// </summary>
|
||||
/// <param name="predicateTypeUri">Canonical predicate type URI.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The entry if found, null otherwise.</returns>
|
||||
Task<PredicateTypeRegistryEntry?> GetByUriAsync(
|
||||
string predicateTypeUri,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new predicate type entry (upsert on URI+version conflict).
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to register.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The registered entry with generated fields populated.</returns>
|
||||
Task<PredicateTypeRegistryEntry> RegisterAsync(
|
||||
PredicateTypeRegistryEntry entry,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the predicate type registry.
|
||||
/// </summary>
|
||||
public sealed record PredicateTypeRegistryEntry
|
||||
{
|
||||
/// <summary>Primary key (UUID).</summary>
|
||||
public Guid RegistryId { get; init; }
|
||||
|
||||
/// <summary>Canonical URI for the predicate type.</summary>
|
||||
public required string PredicateTypeUri { get; init; }
|
||||
|
||||
/// <summary>Human-readable display name.</summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>Semver version string.</summary>
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>Category: stella-core, stella-proof, stella-delta, ecosystem, intoto, custom.</summary>
|
||||
public string Category { get; init; } = "stella-core";
|
||||
|
||||
/// <summary>Optional JSON Schema for payload validation.</summary>
|
||||
public string? JsonSchema { get; init; }
|
||||
|
||||
/// <summary>Optional human-readable description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Whether this predicate type is currently active.</summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Validation mode: log-only, warn, or reject.</summary>
|
||||
public string ValidationMode { get; init; } = "log-only";
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Last update timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresPredicateTypeRegistryRepository.cs
|
||||
// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
// Task: PSR-02 - Create Predicate Schema Registry endpoints and repository
|
||||
// Description: PostgreSQL implementation of predicate type registry repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed predicate type registry repository.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// </summary>
|
||||
public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegistryRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL predicate type registry repository.
|
||||
/// </summary>
|
||||
public PostgresPredicateTypeRegistryRepository(string connectionString)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PredicateTypeRegistryEntry>> ListAsync(
|
||||
string? category = null,
|
||||
bool? isActive = null,
|
||||
int offset = 0,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = @"
|
||||
SELECT registry_id, predicate_type_uri, display_name, version, category,
|
||||
json_schema, description, is_active, validation_mode, created_at, updated_at
|
||||
FROM proofchain.predicate_type_registry
|
||||
WHERE (@category::text IS NULL OR category = @category)
|
||||
AND (@is_active::boolean IS NULL OR is_active = @is_active)
|
||||
ORDER BY category, predicate_type_uri
|
||||
OFFSET @offset LIMIT @limit";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("category", (object?)category ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_active", isActive.HasValue ? isActive.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("offset", offset);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var results = new List<PredicateTypeRegistryEntry>();
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapEntry(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PredicateTypeRegistryEntry?> GetByUriAsync(
|
||||
string predicateTypeUri,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = @"
|
||||
SELECT registry_id, predicate_type_uri, display_name, version, category,
|
||||
json_schema, description, is_active, validation_mode, created_at, updated_at
|
||||
FROM proofchain.predicate_type_registry
|
||||
WHERE predicate_type_uri = @predicate_type_uri
|
||||
ORDER BY version DESC
|
||||
LIMIT 1";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("predicate_type_uri", predicateTypeUri);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PredicateTypeRegistryEntry> RegisterAsync(
|
||||
PredicateTypeRegistryEntry entry,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = @"
|
||||
INSERT INTO proofchain.predicate_type_registry
|
||||
(predicate_type_uri, display_name, version, category, json_schema, description, is_active, validation_mode)
|
||||
VALUES (@predicate_type_uri, @display_name, @version, @category, @json_schema::jsonb, @description, @is_active, @validation_mode)
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING
|
||||
RETURNING registry_id, created_at, updated_at";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("predicate_type_uri", entry.PredicateTypeUri);
|
||||
cmd.Parameters.AddWithValue("display_name", entry.DisplayName);
|
||||
cmd.Parameters.AddWithValue("version", entry.Version);
|
||||
cmd.Parameters.AddWithValue("category", entry.Category);
|
||||
cmd.Parameters.AddWithValue("json_schema", (object?)entry.JsonSchema ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_active", entry.IsActive);
|
||||
cmd.Parameters.AddWithValue("validation_mode", entry.ValidationMode);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return entry with
|
||||
{
|
||||
RegistryId = reader.GetGuid(0),
|
||||
CreatedAt = reader.GetDateTime(1),
|
||||
UpdatedAt = reader.GetDateTime(2),
|
||||
};
|
||||
}
|
||||
|
||||
// Conflict (already exists) - return existing
|
||||
var existing = await GetByUriAsync(entry.PredicateTypeUri, ct);
|
||||
return existing ?? entry;
|
||||
}
|
||||
|
||||
private static PredicateTypeRegistryEntry MapEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
return new PredicateTypeRegistryEntry
|
||||
{
|
||||
RegistryId = reader.GetGuid(0),
|
||||
PredicateTypeUri = reader.GetString(1),
|
||||
DisplayName = reader.GetString(2),
|
||||
Version = reader.GetString(3),
|
||||
Category = reader.GetString(4),
|
||||
JsonSchema = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Description = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
IsActive = reader.GetBoolean(7),
|
||||
ValidationMode = reader.GetString(8),
|
||||
CreatedAt = reader.GetDateTime(9),
|
||||
UpdatedAt = reader.GetDateTime(10),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate model for triage auto-suppress decisions.
|
||||
/// Emitted when a runtime witness confirms a VEX not_affected consensus
|
||||
/// with supporting unreachability evidence.
|
||||
/// Sprint: SPRINT_20260219_012 (MWS-01)
|
||||
/// </summary>
|
||||
public sealed record TriageSuppressPredicate
|
||||
{
|
||||
public const string PredicateTypeUri = "stella.ops/triageSuppress@v1";
|
||||
|
||||
public required string CveId { get; init; }
|
||||
|
||||
public required string SuppressReason { get; init; }
|
||||
|
||||
public required VexConsensusRef VexConsensus { get; init; }
|
||||
|
||||
public required WitnessEvidenceRef WitnessEvidence { get; init; }
|
||||
|
||||
public required string ReachabilityState { get; init; }
|
||||
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
public DeterministicReplayInputs? DeterministicReplayInputs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexConsensusRef
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
|
||||
public string? Justification { get; init; }
|
||||
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
public required string ConsensusDigest { get; init; }
|
||||
|
||||
public int SourceCount { get; init; }
|
||||
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record WitnessEvidenceRef
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
|
||||
public required string DsseDigest { get; init; }
|
||||
|
||||
public required string ObservationType { get; init; }
|
||||
|
||||
public required string PredicateType { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeterministicReplayInputs
|
||||
{
|
||||
public required string CanonicalId { get; init; }
|
||||
|
||||
public required string VexConsensusDigest { get; init; }
|
||||
|
||||
public required string WitnessId { get; init; }
|
||||
}
|
||||
@@ -72,6 +72,9 @@ components:
|
||||
signals:read: Read Signals events and state.
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
trust:admin: Administer trust and signing configuration.
|
||||
trust:read: Read trust and signing state.
|
||||
trust:write: Mutate trust and signing configuration.
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
@@ -127,6 +130,9 @@ components:
|
||||
signals:read: Read Signals events and state.
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
trust:admin: Administer trust and signing configuration.
|
||||
trust:read: Read trust and signing state.
|
||||
trust:write: Mutate trust and signing configuration.
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
@@ -184,6 +190,9 @@ components:
|
||||
signals:read: Read Signals events and state.
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
trust:admin: Administer trust and signing configuration.
|
||||
trust:read: Read trust and signing state.
|
||||
trust:write: Mutate trust and signing configuration.
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
|
||||
@@ -71,6 +71,9 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.EvidenceHold)]
|
||||
[InlineData(StellaOpsScopes.AttestRead)]
|
||||
[InlineData(StellaOpsScopes.ObservabilityIncident)]
|
||||
[InlineData(StellaOpsScopes.TrustRead)]
|
||||
[InlineData(StellaOpsScopes.TrustWrite)]
|
||||
[InlineData(StellaOpsScopes.TrustAdmin)]
|
||||
[InlineData(StellaOpsScopes.AuthorityTenantsRead)]
|
||||
public void All_IncludesNewScopes(string scope)
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public class StellaOpsScopesTests
|
||||
[InlineData("Packs.Run", StellaOpsScopes.PacksRun)]
|
||||
[InlineData("Packs.Approve", StellaOpsScopes.PacksApprove)]
|
||||
[InlineData("Notify.Escalate", StellaOpsScopes.NotifyEscalate)]
|
||||
[InlineData("TRUST:WRITE", StellaOpsScopes.TrustWrite)]
|
||||
[InlineData("VULN:VIEW", StellaOpsScopes.VulnView)]
|
||||
[InlineData("VULN:INVESTIGATE", StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData("VULN:OPERATE", StellaOpsScopes.VulnOperate)]
|
||||
|
||||
@@ -442,6 +442,21 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string UiAdmin = "ui.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to trust and signing state.
|
||||
/// </summary>
|
||||
public const string TrustRead = "trust:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to mutate trust and signing configuration.
|
||||
/// </summary>
|
||||
public const string TrustWrite = "trust:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative control over trust and signing operations.
|
||||
/// </summary>
|
||||
public const string TrustAdmin = "trust:admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Scanner scan results and metadata.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory-source freshness endpoints used by UI v2 shell.
|
||||
/// </summary>
|
||||
internal static class AdvisorySourceEndpointExtensions
|
||||
{
|
||||
private const string AdvisoryReadPolicy = "Concelier.Advisories.Read";
|
||||
|
||||
public static void MapAdvisorySourceEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/advisory-sources")
|
||||
.WithTags("Advisory Sources");
|
||||
|
||||
group.MapGet(string.Empty, async (
|
||||
HttpContext httpContext,
|
||||
[FromQuery] bool includeDisabled,
|
||||
[FromServices] IAdvisorySourceReadRepository readRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
var records = await readRepository.ListAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
|
||||
var items = records.Select(MapListItem).ToList();
|
||||
|
||||
return HttpResults.Ok(new AdvisorySourceListResponse
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = items.Count,
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
});
|
||||
})
|
||||
.WithName("ListAdvisorySources")
|
||||
.WithSummary("List advisory sources with freshness state")
|
||||
.Produces<AdvisorySourceListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
|
||||
group.MapGet("/summary", async (
|
||||
HttpContext httpContext,
|
||||
[FromServices] IAdvisorySourceReadRepository readRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
var records = await readRepository.ListAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AdvisorySourceSummaryResponse
|
||||
{
|
||||
TotalSources = records.Count,
|
||||
HealthySources = records.Count(r => string.Equals(r.FreshnessStatus, "healthy", StringComparison.OrdinalIgnoreCase)),
|
||||
WarningSources = records.Count(r => string.Equals(r.FreshnessStatus, "warning", StringComparison.OrdinalIgnoreCase)),
|
||||
StaleSources = records.Count(r => string.Equals(r.FreshnessStatus, "stale", StringComparison.OrdinalIgnoreCase)),
|
||||
UnavailableSources = records.Count(r => string.Equals(r.FreshnessStatus, "unavailable", StringComparison.OrdinalIgnoreCase)),
|
||||
DisabledSources = records.Count(r => !r.Enabled),
|
||||
ConflictingSources = 0,
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return HttpResults.Ok(response);
|
||||
})
|
||||
.WithName("GetAdvisorySourceSummary")
|
||||
.WithSummary("Get advisory source summary cards")
|
||||
.Produces<AdvisorySourceSummaryResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
|
||||
group.MapGet("/{id}/freshness", async (
|
||||
HttpContext httpContext,
|
||||
string id,
|
||||
[FromServices] IAdvisorySourceReadRepository readRepository,
|
||||
[FromServices] ISourceRepository sourceRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "source_id_required" });
|
||||
}
|
||||
|
||||
id = id.Trim();
|
||||
AdvisorySourceFreshnessRecord? record = null;
|
||||
|
||||
if (Guid.TryParse(id, out var sourceId))
|
||||
{
|
||||
record = await readRepository.GetBySourceIdAsync(sourceId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var source = await sourceRepository.GetByKeyAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (source is not null)
|
||||
{
|
||||
record = await readRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "advisory_source_not_found", id });
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new AdvisorySourceFreshnessResponse
|
||||
{
|
||||
Source = MapListItem(record),
|
||||
LastSyncAt = record.LastSyncAt,
|
||||
LastSuccessAt = record.LastSuccessAt,
|
||||
LastError = record.LastError,
|
||||
SyncCount = record.SyncCount,
|
||||
ErrorCount = record.ErrorCount,
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
});
|
||||
})
|
||||
.WithName("GetAdvisorySourceFreshness")
|
||||
.WithSummary("Get freshness details for one advisory source")
|
||||
.Produces<AdvisorySourceFreshnessResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
}
|
||||
|
||||
private static bool TryGetTenant(HttpContext httpContext, out string tenant)
|
||||
{
|
||||
tenant = string.Empty;
|
||||
|
||||
var claimTenant = httpContext.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
tenant = claimTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var headerTenant = httpContext.Request.Headers[StellaOps.Concelier.WebService.Program.TenantHeaderName].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenant = headerTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static AdvisorySourceListItem MapListItem(AdvisorySourceFreshnessRecord record)
|
||||
{
|
||||
return new AdvisorySourceListItem
|
||||
{
|
||||
SourceId = record.SourceId,
|
||||
SourceKey = record.SourceKey,
|
||||
SourceName = record.SourceName,
|
||||
SourceFamily = record.SourceFamily,
|
||||
SourceUrl = record.SourceUrl,
|
||||
Priority = record.Priority,
|
||||
Enabled = record.Enabled,
|
||||
LastSyncAt = record.LastSyncAt,
|
||||
LastSuccessAt = record.LastSuccessAt,
|
||||
FreshnessAgeSeconds = record.FreshnessAgeSeconds,
|
||||
FreshnessSlaSeconds = record.FreshnessSlaSeconds,
|
||||
FreshnessStatus = record.FreshnessStatus,
|
||||
SignatureStatus = record.SignatureStatus,
|
||||
LastError = record.LastError,
|
||||
SyncCount = record.SyncCount,
|
||||
ErrorCount = record.ErrorCount,
|
||||
TotalAdvisories = record.TotalAdvisories,
|
||||
SignedAdvisories = record.SignedAdvisories,
|
||||
UnsignedAdvisories = record.UnsignedAdvisories,
|
||||
SignatureFailureCount = record.SignatureFailureCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceListResponse
|
||||
{
|
||||
public IReadOnlyList<AdvisorySourceListItem> Items { get; init; } = [];
|
||||
public int TotalCount { get; init; }
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceListItem
|
||||
{
|
||||
public Guid SourceId { get; init; }
|
||||
public string SourceKey { get; init; } = string.Empty;
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
public string SourceFamily { get; init; } = string.Empty;
|
||||
public string? SourceUrl { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public DateTimeOffset? LastSyncAt { get; init; }
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
public long FreshnessAgeSeconds { get; init; }
|
||||
public int FreshnessSlaSeconds { get; init; }
|
||||
public string FreshnessStatus { get; init; } = "unknown";
|
||||
public string SignatureStatus { get; init; } = "unsigned";
|
||||
public string? LastError { get; init; }
|
||||
public long SyncCount { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
public long TotalAdvisories { get; init; }
|
||||
public long SignedAdvisories { get; init; }
|
||||
public long UnsignedAdvisories { get; init; }
|
||||
public long SignatureFailureCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceSummaryResponse
|
||||
{
|
||||
public int TotalSources { get; init; }
|
||||
public int HealthySources { get; init; }
|
||||
public int WarningSources { get; init; }
|
||||
public int StaleSources { get; init; }
|
||||
public int UnavailableSources { get; init; }
|
||||
public int DisabledSources { get; init; }
|
||||
public int ConflictingSources { get; init; }
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceFreshnessResponse
|
||||
{
|
||||
public AdvisorySourceListItem Source { get; init; } = new();
|
||||
public DateTimeOffset? LastSyncAt { get; init; }
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
public string? LastError { get; init; }
|
||||
public long SyncCount { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
@@ -909,6 +909,7 @@ app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
|
||||
|
||||
// Canonical advisory endpoints (Sprint 8200.0012.0003)
|
||||
app.MapCanonicalAdvisoryEndpoints();
|
||||
app.MapAdvisorySourceEndpoints();
|
||||
app.MapInterestScoreEndpoints();
|
||||
|
||||
// Federation endpoints for site-to-site bundle sync
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0242-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0242-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0242-A | DONE | Applied 2026-01-13; TimeProvider defaults, ASCII cleanup, federation tests. |
|
||||
| BE8-07-API | DONE | Advisory-source freshness endpoint contract extended with advisory stats fields consumed by UI security diagnostics. |
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class ConcelierPersistenceExtensions
|
||||
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
|
||||
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
|
||||
services.AddScoped<ISourceRepository, SourceRepository>();
|
||||
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
|
||||
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
|
||||
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
|
||||
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
|
||||
@@ -87,6 +88,7 @@ public static class ConcelierPersistenceExtensions
|
||||
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
|
||||
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
|
||||
services.AddScoped<ISourceRepository, SourceRepository>();
|
||||
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
|
||||
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
|
||||
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
|
||||
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Concelier migration 004: advisory source freshness projection support
|
||||
-- Sprint: SPRINT_20260219_008 (BE8-04)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vuln.source_freshness_sla (
|
||||
source_id UUID PRIMARY KEY REFERENCES vuln.sources(id) ON DELETE CASCADE,
|
||||
sla_seconds INT NOT NULL DEFAULT 21600 CHECK (sla_seconds > 0),
|
||||
warning_ratio NUMERIC(4,2) NOT NULL DEFAULT 0.80 CHECK (warning_ratio > 0 AND warning_ratio < 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
COMMENT ON TABLE vuln.source_freshness_sla IS
|
||||
'Freshness SLA thresholds per advisory source for advisory-sources UI contracts.';
|
||||
|
||||
INSERT INTO vuln.source_freshness_sla (source_id)
|
||||
SELECT s.id
|
||||
FROM vuln.sources s
|
||||
ON CONFLICT (source_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_source_states_last_success_at
|
||||
ON vuln.source_states (last_success_at DESC)
|
||||
WHERE last_success_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_source_states_last_sync_at
|
||||
ON vuln.source_states (last_sync_at DESC)
|
||||
WHERE last_sync_at IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_source_freshness_sla_updated_at ON vuln.source_freshness_sla;
|
||||
CREATE TRIGGER trg_source_freshness_sla_updated_at
|
||||
BEFORE UPDATE ON vuln.source_freshness_sla
|
||||
FOR EACH ROW EXECUTE FUNCTION vuln.update_updated_at();
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Concelier migration 005: advisory-source signature projection support
|
||||
-- Sprint: SPRINT_20260219_008 (BE8-07)
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisories_source_key
|
||||
ON vuln.advisories (source_id, advisory_key)
|
||||
WHERE source_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_source_advisory
|
||||
ON vuln.advisory_source_edge (source_id, source_advisory_id);
|
||||
|
||||
CREATE OR REPLACE VIEW vuln.advisory_source_signature_projection AS
|
||||
WITH advisory_totals AS (
|
||||
SELECT
|
||||
a.source_id,
|
||||
COUNT(*)::BIGINT AS total_advisories
|
||||
FROM vuln.advisories a
|
||||
WHERE a.source_id IS NOT NULL
|
||||
GROUP BY a.source_id
|
||||
),
|
||||
signed_totals AS (
|
||||
SELECT
|
||||
a.source_id,
|
||||
COUNT(*)::BIGINT AS signed_advisories
|
||||
FROM vuln.advisories a
|
||||
WHERE a.source_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM vuln.advisory_source_edge e
|
||||
WHERE e.source_id = a.source_id
|
||||
AND e.source_advisory_id = a.advisory_key
|
||||
AND e.dsse_envelope IS NOT NULL
|
||||
AND CASE
|
||||
WHEN jsonb_typeof(e.dsse_envelope->'signatures') = 'array'
|
||||
THEN jsonb_array_length(e.dsse_envelope->'signatures') > 0
|
||||
ELSE FALSE
|
||||
END
|
||||
)
|
||||
GROUP BY a.source_id
|
||||
),
|
||||
failure_totals AS (
|
||||
SELECT
|
||||
ss.source_id,
|
||||
CASE
|
||||
WHEN ss.metadata ? 'signature_failure_count'
|
||||
AND (ss.metadata->>'signature_failure_count') ~ '^[0-9]+$'
|
||||
THEN (ss.metadata->>'signature_failure_count')::BIGINT
|
||||
ELSE 0::BIGINT
|
||||
END AS signature_failure_count
|
||||
FROM vuln.source_states ss
|
||||
)
|
||||
SELECT
|
||||
s.id AS source_id,
|
||||
COALESCE(t.total_advisories, 0)::BIGINT AS total_advisories,
|
||||
LEAST(
|
||||
COALESCE(t.total_advisories, 0)::BIGINT,
|
||||
COALESCE(st.signed_advisories, 0)::BIGINT
|
||||
) AS signed_advisories,
|
||||
GREATEST(
|
||||
COALESCE(t.total_advisories, 0)::BIGINT
|
||||
- LEAST(
|
||||
COALESCE(t.total_advisories, 0)::BIGINT,
|
||||
COALESCE(st.signed_advisories, 0)::BIGINT
|
||||
),
|
||||
0::BIGINT
|
||||
) AS unsigned_advisories,
|
||||
COALESCE(f.signature_failure_count, 0)::BIGINT AS signature_failure_count
|
||||
FROM vuln.sources s
|
||||
LEFT JOIN advisory_totals t ON t.source_id = s.id
|
||||
LEFT JOIN signed_totals st ON st.source_id = s.id
|
||||
LEFT JOIN failure_totals f ON f.source_id = s.id;
|
||||
|
||||
COMMENT ON VIEW vuln.advisory_source_signature_projection IS
|
||||
'Per-source advisory totals and signature rollups for advisory-source detail diagnostics.';
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed read model for advisory source freshness contracts.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourceReadRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySourceReadRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
|
||||
public AdvisorySourceReadRepository(
|
||||
ConcelierDataSource dataSource,
|
||||
ILogger<AdvisorySourceReadRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
|
||||
bool includeDisabled = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH source_projection AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.key,
|
||||
s.name,
|
||||
s.source_type,
|
||||
s.url,
|
||||
s.priority,
|
||||
s.enabled,
|
||||
st.last_sync_at,
|
||||
st.last_success_at,
|
||||
st.last_error,
|
||||
COALESCE(st.sync_count, 0) AS sync_count,
|
||||
COALESCE(st.error_count, 0) AS error_count,
|
||||
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
|
||||
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
|
||||
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
|
||||
COALESCE(sig.total_advisories, 0) AS total_advisories,
|
||||
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
|
||||
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
|
||||
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
|
||||
FROM vuln.sources s
|
||||
LEFT JOIN vuln.source_states st ON st.source_id = s.id
|
||||
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
|
||||
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
|
||||
WHERE (@include_disabled OR s.enabled = TRUE)
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
source_type,
|
||||
url,
|
||||
priority,
|
||||
enabled,
|
||||
last_sync_at,
|
||||
last_success_at,
|
||||
last_error,
|
||||
sync_count,
|
||||
error_count,
|
||||
freshness_sla_seconds,
|
||||
warning_ratio,
|
||||
CAST(
|
||||
EXTRACT(EPOCH FROM (
|
||||
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
|
||||
)) AS BIGINT) AS freshness_age_seconds,
|
||||
CASE
|
||||
WHEN last_success_at IS NULL THEN 'unavailable'
|
||||
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
|
||||
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
|
||||
ELSE 'healthy'
|
||||
END AS freshness_status,
|
||||
signature_status,
|
||||
total_advisories,
|
||||
signed_advisories,
|
||||
unsigned_advisories,
|
||||
signature_failure_count
|
||||
FROM source_projection
|
||||
ORDER BY enabled DESC, priority DESC, key
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "include_disabled", includeDisabled),
|
||||
MapRecord,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH source_projection AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.key,
|
||||
s.name,
|
||||
s.source_type,
|
||||
s.url,
|
||||
s.priority,
|
||||
s.enabled,
|
||||
st.last_sync_at,
|
||||
st.last_success_at,
|
||||
st.last_error,
|
||||
COALESCE(st.sync_count, 0) AS sync_count,
|
||||
COALESCE(st.error_count, 0) AS error_count,
|
||||
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
|
||||
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
|
||||
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
|
||||
COALESCE(sig.total_advisories, 0) AS total_advisories,
|
||||
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
|
||||
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
|
||||
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
|
||||
FROM vuln.sources s
|
||||
LEFT JOIN vuln.source_states st ON st.source_id = s.id
|
||||
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
|
||||
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
|
||||
WHERE s.id = @source_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
source_type,
|
||||
url,
|
||||
priority,
|
||||
enabled,
|
||||
last_sync_at,
|
||||
last_success_at,
|
||||
last_error,
|
||||
sync_count,
|
||||
error_count,
|
||||
freshness_sla_seconds,
|
||||
warning_ratio,
|
||||
CAST(
|
||||
EXTRACT(EPOCH FROM (
|
||||
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
|
||||
)) AS BIGINT) AS freshness_age_seconds,
|
||||
CASE
|
||||
WHEN last_success_at IS NULL THEN 'unavailable'
|
||||
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
|
||||
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
|
||||
ELSE 'healthy'
|
||||
END AS freshness_status,
|
||||
signature_status,
|
||||
total_advisories,
|
||||
signed_advisories,
|
||||
unsigned_advisories,
|
||||
signature_failure_count
|
||||
FROM source_projection
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "source_id", sourceId),
|
||||
MapRecord,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static AdvisorySourceFreshnessRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdvisorySourceFreshnessRecord(
|
||||
SourceId: reader.GetGuid(0),
|
||||
SourceKey: reader.GetString(1),
|
||||
SourceName: reader.GetString(2),
|
||||
SourceFamily: reader.GetString(3),
|
||||
SourceUrl: GetNullableString(reader, 4),
|
||||
Priority: reader.GetInt32(5),
|
||||
Enabled: reader.GetBoolean(6),
|
||||
LastSyncAt: GetNullableDateTimeOffset(reader, 7),
|
||||
LastSuccessAt: GetNullableDateTimeOffset(reader, 8),
|
||||
LastError: GetNullableString(reader, 9),
|
||||
SyncCount: reader.GetInt64(10),
|
||||
ErrorCount: reader.GetInt32(11),
|
||||
FreshnessSlaSeconds: reader.GetInt32(12),
|
||||
WarningRatio: reader.GetDecimal(13),
|
||||
FreshnessAgeSeconds: reader.GetInt64(14),
|
||||
FreshnessStatus: reader.GetString(15),
|
||||
SignatureStatus: reader.GetString(16),
|
||||
TotalAdvisories: reader.GetInt64(17),
|
||||
SignedAdvisories: reader.GetInt64(18),
|
||||
UnsignedAdvisories: reader.GetInt64(19),
|
||||
SignatureFailureCount: reader.GetInt64(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Read-model repository for advisory source freshness surfaces.
|
||||
/// </summary>
|
||||
public interface IAdvisorySourceReadRepository
|
||||
{
|
||||
Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
|
||||
bool includeDisabled = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceFreshnessRecord(
|
||||
Guid SourceId,
|
||||
string SourceKey,
|
||||
string SourceName,
|
||||
string SourceFamily,
|
||||
string? SourceUrl,
|
||||
int Priority,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
string? LastError,
|
||||
long SyncCount,
|
||||
int ErrorCount,
|
||||
int FreshnessSlaSeconds,
|
||||
decimal WarningRatio,
|
||||
long FreshnessAgeSeconds,
|
||||
string FreshnessStatus,
|
||||
string SignatureStatus,
|
||||
long TotalAdvisories,
|
||||
long SignedAdvisories,
|
||||
long UnsignedAdvisories,
|
||||
long SignatureFailureCount);
|
||||
@@ -41,6 +41,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
|
||||
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
|
||||
services.AddScoped<ISourceRepository, SourceRepository>();
|
||||
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
|
||||
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
|
||||
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
|
||||
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
|
||||
@@ -90,6 +91,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
|
||||
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
|
||||
services.AddScoped<ISourceRepository, SourceRepository>();
|
||||
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
|
||||
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
|
||||
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
|
||||
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
|
||||
|
||||
@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: restore reference summaries from raw payload. |
|
||||
| TASK-015-011 | DONE | Added enriched SBOM storage table + Postgres repository. |
|
||||
| TASK-015-007d | DONE | Added license indexes and repository queries for license inventory. |
|
||||
| BE8-07 | DONE | Added migration `005_add_advisory_source_signature_projection.sql` and advisory-source signature stats projection fields for UI detail diagnostics. |
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Encodings.Web;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Concelier.WebService.Extensions;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
public AdvisorySourceWebAppFactory()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=test-advisory-sources");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-advisory-sources");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
var overrides = new Dictionary<string, string?>
|
||||
{
|
||||
{ "PostgresStorage:ConnectionString", "Host=localhost;Port=5432;Database=test-advisory-sources" },
|
||||
{ "PostgresStorage:CommandTimeoutSeconds", "30" },
|
||||
{ "Telemetry:Enabled", "false" }
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(overrides);
|
||||
});
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, static _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
// Endpoint behavior in this test suite focuses on tenant/header/repository behavior.
|
||||
// Authorization policy is exercised in dedicated auth coverage tests.
|
||||
options.AddPolicy("Concelier.Advisories.Read", policy => policy.RequireAssertion(static _ => true));
|
||||
});
|
||||
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
|
||||
services.RemoveAll<IAdvisorySourceReadRepository>();
|
||||
services.AddSingleton<IAdvisorySourceReadRepository, StubAdvisorySourceReadRepository>();
|
||||
|
||||
services.RemoveAll<ISourceRepository>();
|
||||
services.AddSingleton<ISourceRepository, StubSourceRepository>();
|
||||
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
||||
{
|
||||
ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources",
|
||||
CommandTimeoutSeconds = 30
|
||||
},
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(_ => new ConfigureOptions<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
|
||||
opts.PostgresStorage.ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources";
|
||||
opts.PostgresStorage.CommandTimeoutSeconds = 30;
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdvisorySourceTests";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests")
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAdvisorySourceReadRepository : IAdvisorySourceReadRepository
|
||||
{
|
||||
private static readonly AdvisorySourceFreshnessRecord[] Records =
|
||||
[
|
||||
new(
|
||||
SourceId: Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
|
||||
SourceKey: "nvd",
|
||||
SourceName: "NVD",
|
||||
SourceFamily: "nvd",
|
||||
SourceUrl: "https://nvd.nist.gov",
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
LastSyncAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
|
||||
LastError: null,
|
||||
SyncCount: 220,
|
||||
ErrorCount: 1,
|
||||
FreshnessSlaSeconds: 14400,
|
||||
WarningRatio: 0.8m,
|
||||
FreshnessAgeSeconds: 3600,
|
||||
FreshnessStatus: "healthy",
|
||||
SignatureStatus: "signed",
|
||||
TotalAdvisories: 220,
|
||||
SignedAdvisories: 215,
|
||||
UnsignedAdvisories: 5,
|
||||
SignatureFailureCount: 1),
|
||||
new(
|
||||
SourceId: Guid.Parse("fc9d6356-01d8-4012-8ce7-31e0f983f8c3"),
|
||||
SourceKey: "ghsa",
|
||||
SourceName: "GHSA",
|
||||
SourceFamily: "ghsa",
|
||||
SourceUrl: "https://github.com/advisories",
|
||||
Priority: 80,
|
||||
Enabled: false,
|
||||
LastSyncAt: DateTimeOffset.Parse("2026-02-19T01:00:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-02-18T20:30:00Z"),
|
||||
LastError: "timeout",
|
||||
SyncCount: 200,
|
||||
ErrorCount: 8,
|
||||
FreshnessSlaSeconds: 14400,
|
||||
WarningRatio: 0.8m,
|
||||
FreshnessAgeSeconds: 43200,
|
||||
FreshnessStatus: "stale",
|
||||
SignatureStatus: "unsigned",
|
||||
TotalAdvisories: 200,
|
||||
SignedAdvisories: 0,
|
||||
UnsignedAdvisories: 200,
|
||||
SignatureFailureCount: 0)
|
||||
];
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
|
||||
bool includeDisabled = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<AdvisorySourceFreshnessRecord> items = includeDisabled
|
||||
? Records
|
||||
: Records.Where(static record => record.Enabled).ToList();
|
||||
return Task.FromResult(items);
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Records.FirstOrDefault(record => record.SourceId == sourceId));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSourceRepository : ISourceRepository
|
||||
{
|
||||
private static readonly IReadOnlyList<SourceEntity> Sources =
|
||||
[
|
||||
new SourceEntity
|
||||
{
|
||||
Id = Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
|
||||
Key = "nvd",
|
||||
Name = "NVD",
|
||||
SourceType = "nvd",
|
||||
Url = "https://nvd.nist.gov",
|
||||
Priority = 100,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-18T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z")
|
||||
}
|
||||
];
|
||||
|
||||
public Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(source);
|
||||
|
||||
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Sources.FirstOrDefault(source => source.Id == id));
|
||||
|
||||
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Sources.FirstOrDefault(source => string.Equals(source.Key, key, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Sources
|
||||
.Where(source => enabled is null || source.Enabled == enabled.Value)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<SourceEntity>>(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisorySourceEndpointsTests : IClassFixture<AdvisorySourceWebAppFactory>
|
||||
{
|
||||
private readonly AdvisorySourceWebAppFactory _factory;
|
||||
|
||||
public AdvisorySourceEndpointsTests(AdvisorySourceWebAppFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListEndpoints_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources", CancellationToken.None);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListEndpoints_WithTenantHeader_ReturnsRecords()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources?includeDisabled=true", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceListResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.TotalCount);
|
||||
Assert.Contains(payload.Items, static item => item.SourceKey == "nvd");
|
||||
Assert.Contains(payload.Items, static item => item.SourceKey == "ghsa");
|
||||
var nvd = payload.Items.Single(static item => item.SourceKey == "nvd");
|
||||
Assert.Equal(220, nvd.TotalAdvisories);
|
||||
Assert.Equal(215, nvd.SignedAdvisories);
|
||||
Assert.Equal(5, nvd.UnsignedAdvisories);
|
||||
Assert.Equal(1, nvd.SignatureFailureCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SummaryEndpoint_ReturnsExpectedCounts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/summary", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceSummaryResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.TotalSources);
|
||||
Assert.Equal(1, payload.HealthySources);
|
||||
Assert.Equal(1, payload.StaleSources);
|
||||
Assert.Equal(1, payload.DisabledSources);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FreshnessEndpoint_ByKey_ReturnsRecord()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/freshness", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceFreshnessResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("nvd", payload!.Source.SourceKey);
|
||||
Assert.Equal("healthy", payload.Source.FreshnessStatus);
|
||||
Assert.Equal(220, payload.Source.TotalAdvisories);
|
||||
Assert.Equal(215, payload.Source.SignedAdvisories);
|
||||
Assert.Equal(5, payload.Source.UnsignedAdvisories);
|
||||
Assert.Equal(1, payload.Source.SignatureFailureCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FreshnessEndpoint_UnknownSource_ReturnsNotFound()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/unknown-source/freshness", CancellationToken.None);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0243-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0243-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0243-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| BE8-07-TEST | DONE | Extended advisory-source endpoint coverage for total/signed/unsigned/signature-failure contract fields. |
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Pack-driven Evidence & Audit adapter routes.
|
||||
/// </summary>
|
||||
public static class EvidenceAuditEndpoints
|
||||
{
|
||||
private static readonly DateTimeOffset SnapshotAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
|
||||
|
||||
private static readonly IReadOnlyList<EvidencePackSummaryDto> Packs =
|
||||
[
|
||||
new EvidencePackSummaryDto("pack-9001", "rel-003", "us-prod", "1.2.4", "sealed", "2026-02-18T08:33:00Z"),
|
||||
new EvidencePackSummaryDto("pack-9002", "rel-002", "us-uat", "1.3.0-rc1", "sealed", "2026-02-18T07:30:00Z"),
|
||||
new EvidencePackSummaryDto("pack-9003", "rel-001", "eu-prod", "1.2.3", "sealed", "2026-02-17T08:30:00Z"),
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, EvidencePackDetailDto> PackDetails =
|
||||
new Dictionary<string, EvidencePackDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pack-9001"] = new(
|
||||
PackId: "pack-9001",
|
||||
ReleaseId: "rel-003",
|
||||
Environment: "us-prod",
|
||||
BundleVersion: "1.2.4",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000003",
|
||||
Decision: "pass_with_ack",
|
||||
PromotionRunId: "run-7712",
|
||||
Artifacts:
|
||||
[
|
||||
new EvidencePackArtifactDto("sbom", "spdx", "sha256:sbom-9001"),
|
||||
new EvidencePackArtifactDto("findings", "json", "sha256:findings-9001"),
|
||||
new EvidencePackArtifactDto("policy-decision", "dsse", "sha256:policy-9001"),
|
||||
new EvidencePackArtifactDto("vex", "openvex", "sha256:vex-9001"),
|
||||
],
|
||||
ProofChainId: "chain-9912")
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ProofChainDetailDto> ProofsByDigest =
|
||||
new Dictionary<string, ProofChainDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sha256:beef000000000000000000000000000000000000000000000000000000000003"] = new(
|
||||
ChainId: "chain-9912",
|
||||
SubjectDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000003",
|
||||
Status: "valid",
|
||||
DsseEnvelope: "dsse://pack-9001",
|
||||
RekorEntry: "rekor://entry/9912",
|
||||
VerifiedAt: "2026-02-19T03:10:00Z")
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, CvssReceiptDto> CvssReceipts =
|
||||
new Dictionary<string, CvssReceiptDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["CVE-2026-1234"] = new(
|
||||
VulnerabilityId: "CVE-2026-1234",
|
||||
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
BaseScore: 9.8m,
|
||||
ScoredAt: "2026-02-18T08:21:00Z",
|
||||
Source: "nvd")
|
||||
};
|
||||
|
||||
public static void MapEvidenceAuditEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/evidence")
|
||||
.WithTags("Evidence Audit");
|
||||
|
||||
group.MapGet(string.Empty, GetHome)
|
||||
.WithName("GetEvidenceHome")
|
||||
.WithSummary("Get evidence home summary and quick links.")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/packs", ListPacks)
|
||||
.WithName("ListEvidencePacks")
|
||||
.WithSummary("List evidence packs.")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/packs/{id}", GetPackDetail)
|
||||
.WithName("GetEvidencePack")
|
||||
.WithSummary("Get evidence pack detail.")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/proofs/{subjectDigest}", GetProofChain)
|
||||
.WithName("GetEvidenceProofChain")
|
||||
.WithSummary("Get proof chain by subject digest.")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/audit", ListAudit)
|
||||
.WithName("ListEvidenceAuditLog")
|
||||
.WithSummary("Get unified evidence audit log slice.")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/receipts/cvss/{id}", GetCvssReceipt)
|
||||
.WithName("GetCvssReceipt")
|
||||
.WithSummary("Get CVSS receipt by vulnerability id.")
|
||||
.RequireAuthorization();
|
||||
}
|
||||
|
||||
private static IResult GetHome()
|
||||
{
|
||||
var home = new EvidenceHomeDto(
|
||||
GeneratedAt: SnapshotAt,
|
||||
QuickStats: new EvidenceQuickStatsDto(
|
||||
LatestPacks24h: 3,
|
||||
SealedBundles7d: 5,
|
||||
FailedVerifications7d: 1,
|
||||
TrustAlerts30d: 1),
|
||||
LatestPacks: Packs.OrderBy(item => item.PackId, StringComparer.Ordinal).Take(3).ToList(),
|
||||
LatestBundles:
|
||||
[
|
||||
"bundle-2026-02-18-us-prod",
|
||||
"bundle-2026-02-18-us-uat",
|
||||
],
|
||||
FailedVerifications:
|
||||
[
|
||||
"rr-002 (determinism mismatch)",
|
||||
]);
|
||||
|
||||
return Results.Ok(home);
|
||||
}
|
||||
|
||||
private static IResult ListPacks()
|
||||
{
|
||||
var items = Packs
|
||||
.OrderBy(item => item.PackId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new EvidencePackListResponseDto(items, items.Count, SnapshotAt));
|
||||
}
|
||||
|
||||
private static IResult GetPackDetail(string id)
|
||||
{
|
||||
return PackDetails.TryGetValue(id, out var detail)
|
||||
? Results.Ok(detail)
|
||||
: Results.NotFound(new { error = "pack_not_found", id });
|
||||
}
|
||||
|
||||
private static IResult GetProofChain(string subjectDigest)
|
||||
{
|
||||
return ProofsByDigest.TryGetValue(subjectDigest, out var proof)
|
||||
? Results.Ok(proof)
|
||||
: Results.NotFound(new { error = "proof_not_found", subjectDigest });
|
||||
}
|
||||
|
||||
private static IResult ListAudit([FromQuery] int? limit = null)
|
||||
{
|
||||
var max = Math.Clamp(limit ?? 50, 1, 200);
|
||||
var events = new[]
|
||||
{
|
||||
new EvidenceAuditEventDto("evt-3001", "export.created", "run-8811", "2026-02-18T08:40:00Z"),
|
||||
new EvidenceAuditEventDto("evt-3002", "pack.sealed", "pack-9001", "2026-02-18T08:33:00Z"),
|
||||
new EvidenceAuditEventDto("evt-3003", "trust.certificate-rotated", "issuer-registryca", "2026-02-18T07:10:00Z"),
|
||||
}.OrderBy(eventRow => eventRow.EventId, StringComparer.Ordinal).Take(max).ToList();
|
||||
|
||||
return Results.Ok(new EvidenceAuditResponseDto(events, events.Count, SnapshotAt));
|
||||
}
|
||||
|
||||
private static IResult GetCvssReceipt(string id)
|
||||
{
|
||||
return CvssReceipts.TryGetValue(id, out var receipt)
|
||||
? Results.Ok(receipt)
|
||||
: Results.NotFound(new { error = "cvss_receipt_not_found", id });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EvidenceHomeDto(
|
||||
DateTimeOffset GeneratedAt,
|
||||
EvidenceQuickStatsDto QuickStats,
|
||||
IReadOnlyList<EvidencePackSummaryDto> LatestPacks,
|
||||
IReadOnlyList<string> LatestBundles,
|
||||
IReadOnlyList<string> FailedVerifications);
|
||||
|
||||
public sealed record EvidenceQuickStatsDto(
|
||||
int LatestPacks24h,
|
||||
int SealedBundles7d,
|
||||
int FailedVerifications7d,
|
||||
int TrustAlerts30d);
|
||||
|
||||
public sealed record EvidencePackListResponseDto(
|
||||
IReadOnlyList<EvidencePackSummaryDto> Items,
|
||||
int Total,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record EvidencePackSummaryDto(
|
||||
string PackId,
|
||||
string ReleaseId,
|
||||
string Environment,
|
||||
string BundleVersion,
|
||||
string Status,
|
||||
string CreatedAt);
|
||||
|
||||
public sealed record EvidencePackDetailDto(
|
||||
string PackId,
|
||||
string ReleaseId,
|
||||
string Environment,
|
||||
string BundleVersion,
|
||||
string ManifestDigest,
|
||||
string Decision,
|
||||
string PromotionRunId,
|
||||
IReadOnlyList<EvidencePackArtifactDto> Artifacts,
|
||||
string ProofChainId);
|
||||
|
||||
public sealed record EvidencePackArtifactDto(
|
||||
string Kind,
|
||||
string Format,
|
||||
string Digest);
|
||||
|
||||
public sealed record ProofChainDetailDto(
|
||||
string ChainId,
|
||||
string SubjectDigest,
|
||||
string Status,
|
||||
string DsseEnvelope,
|
||||
string RekorEntry,
|
||||
string VerifiedAt);
|
||||
|
||||
public sealed record EvidenceAuditResponseDto(
|
||||
IReadOnlyList<EvidenceAuditEventDto> Items,
|
||||
int Total,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record EvidenceAuditEventDto(
|
||||
string EventId,
|
||||
string EventType,
|
||||
string Subject,
|
||||
string OccurredAt);
|
||||
|
||||
public sealed record CvssReceiptDto(
|
||||
string VulnerabilityId,
|
||||
string CvssVector,
|
||||
decimal BaseScore,
|
||||
string ScoredAt,
|
||||
string Source);
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Response for GET /api/v1/evidence/thread/{canonicalId}.
|
||||
/// Represents the Artifact Canonical Record per docs/contracts/artifact-canonical-record-v1.md.
|
||||
/// Sprint: SPRINT_20260219_009 (CID-04)
|
||||
/// </summary>
|
||||
public sealed record GetEvidenceThreadResponse
|
||||
{
|
||||
[JsonPropertyName("canonical_id")]
|
||||
public required string CanonicalId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_digest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("attestations")]
|
||||
public required IReadOnlyList<EvidenceThreadAttestation> Attestations { get; init; }
|
||||
|
||||
[JsonPropertyName("transparency_status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public TransparencyStatus? TransparencyStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attestation record within an evidence thread.
|
||||
/// </summary>
|
||||
public sealed record EvidenceThreadAttestation
|
||||
{
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("dsse_digest")]
|
||||
public required string DsseDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_keyid")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("rekor_entry_id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("rekor_tile")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorTile { get; init; }
|
||||
|
||||
[JsonPropertyName("signed_at")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log status for offline/air-gapped deployments.
|
||||
/// </summary>
|
||||
public sealed record TransparencyStatus
|
||||
{
|
||||
[JsonPropertyName("mode")]
|
||||
public required string Mode { get; init; } // "online" | "offline"
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for GET /api/v1/evidence/thread?purl={purl} (PURL-based lookup).
|
||||
/// </summary>
|
||||
public sealed record ListEvidenceThreadsResponse
|
||||
{
|
||||
[JsonPropertyName("threads")]
|
||||
public required IReadOnlyList<EvidenceThreadSummary> Threads { get; init; }
|
||||
|
||||
[JsonPropertyName("pagination")]
|
||||
public required PaginationInfo Pagination { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an evidence thread (for list responses).
|
||||
/// </summary>
|
||||
public sealed record EvidenceThreadSummary
|
||||
{
|
||||
[JsonPropertyName("canonical_id")]
|
||||
public required string CanonicalId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation_count")]
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Storage;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Logging category for evidence thread endpoints.
|
||||
/// </summary>
|
||||
internal sealed class EvidenceThreadEndpointsLogger;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for the Evidence Thread API.
|
||||
/// Returns Artifact Canonical Records per docs/contracts/artifact-canonical-record-v1.md.
|
||||
/// Sprint: SPRINT_20260219_009 (CID-04)
|
||||
/// </summary>
|
||||
public static class EvidenceThreadEndpoints
|
||||
{
|
||||
public static void MapEvidenceThreadEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/evidence/thread")
|
||||
.WithTags("Evidence Threads");
|
||||
|
||||
// GET /api/v1/evidence/thread/{canonicalId}
|
||||
group.MapGet("/{canonicalId}", GetThreadByCanonicalIdAsync)
|
||||
.WithName("GetEvidenceThread")
|
||||
.WithSummary("Retrieve the evidence thread for an artifact by canonical_id")
|
||||
.Produces<GetEvidenceThreadResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// GET /api/v1/evidence/thread?purl={purl}
|
||||
group.MapGet("/", ListThreadsByPurlAsync)
|
||||
.WithName("ListEvidenceThreads")
|
||||
.WithSummary("List evidence threads matching a PURL")
|
||||
.Produces<ListEvidenceThreadsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetThreadByCanonicalIdAsync(
|
||||
string canonicalId,
|
||||
[FromServices] IEvidenceThreadRepository repository,
|
||||
[FromServices] ILogger<EvidenceThreadEndpointsLogger> logger,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] bool include_attestations = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Retrieving evidence thread for canonical_id {CanonicalId}", canonicalId);
|
||||
|
||||
var record = await repository.GetByCanonicalIdAsync(canonicalId, cancellationToken);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
logger.LogWarning("Evidence thread not found for canonical_id {CanonicalId}", canonicalId);
|
||||
return Results.NotFound(new { error = "Evidence thread not found", canonical_id = canonicalId });
|
||||
}
|
||||
|
||||
var attestations = ParseAttestations(record.Attestations);
|
||||
|
||||
var response = new GetEvidenceThreadResponse
|
||||
{
|
||||
CanonicalId = record.CanonicalId,
|
||||
Format = record.Format,
|
||||
ArtifactDigest = record.ArtifactDigest,
|
||||
Purl = record.Purl,
|
||||
Attestations = include_attestations ? attestations : [],
|
||||
CreatedAt = record.CreatedAt
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error retrieving evidence thread for canonical_id {CanonicalId}", canonicalId);
|
||||
return Results.Problem(
|
||||
title: "Internal server error",
|
||||
detail: "Failed to retrieve evidence thread",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListThreadsByPurlAsync(
|
||||
[FromServices] IEvidenceThreadRepository repository,
|
||||
[FromServices] ILogger<EvidenceThreadEndpointsLogger> logger,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? purl = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return Results.BadRequest(new { error = "purl query parameter is required" });
|
||||
}
|
||||
|
||||
logger.LogInformation("Listing evidence threads for PURL {Purl}", purl);
|
||||
|
||||
var records = await repository.GetByPurlAsync(purl, cancellationToken);
|
||||
|
||||
var threads = records.Select(r =>
|
||||
{
|
||||
var attestations = ParseAttestations(r.Attestations);
|
||||
return new EvidenceThreadSummary
|
||||
{
|
||||
CanonicalId = r.CanonicalId,
|
||||
Format = r.Format,
|
||||
Purl = r.Purl,
|
||||
AttestationCount = attestations.Count,
|
||||
CreatedAt = r.CreatedAt
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var response = new ListEvidenceThreadsResponse
|
||||
{
|
||||
Threads = threads,
|
||||
Pagination = new PaginationInfo
|
||||
{
|
||||
Total = threads.Count,
|
||||
Limit = 100,
|
||||
Offset = 0
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error listing evidence threads for PURL {Purl}", purl);
|
||||
return Results.Problem(
|
||||
title: "Internal server error",
|
||||
detail: "Failed to list evidence threads",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSONB attestations array from the materialized view into typed records.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EvidenceThreadAttestation> ParseAttestations(string attestationsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attestationsJson) || attestationsJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(attestationsJson);
|
||||
var results = new List<EvidenceThreadAttestation>();
|
||||
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var predicateType = element.GetProperty("predicate_type").GetString();
|
||||
var dsseDigest = element.GetProperty("dsse_digest").GetString();
|
||||
var signedAtRaw = element.GetProperty("signed_at").GetString();
|
||||
|
||||
if (predicateType is null || dsseDigest is null || signedAtRaw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new EvidenceThreadAttestation
|
||||
{
|
||||
PredicateType = predicateType,
|
||||
DsseDigest = dsseDigest,
|
||||
SignerKeyId = element.TryGetProperty("signer_keyid", out var sk) ? sk.GetString() : null,
|
||||
RekorEntryId = element.TryGetProperty("rekor_entry_id", out var re) ? re.GetString() : null,
|
||||
RekorTile = element.TryGetProperty("rekor_tile", out var rt) ? rt.GetString() : null,
|
||||
SignedAt = DateTimeOffset.Parse(signedAtRaw)
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering: signed_at ascending, then predicate_type ascending
|
||||
return results
|
||||
.OrderBy(a => a.SignedAt)
|
||||
.ThenBy(a => a.PredicateType, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,17 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
logger);
|
||||
});
|
||||
|
||||
// Evidence Thread repository (Artifact Canonical Record API)
|
||||
// Sprint: SPRINT_20260219_009 (CID-04)
|
||||
services.AddScoped<StellaOps.EvidenceLocker.Storage.IEvidenceThreadRepository>(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
|
||||
var logger = provider.GetRequiredService<ILogger<StellaOps.EvidenceLocker.Storage.PostgresEvidenceThreadRepository>>();
|
||||
return new StellaOps.EvidenceLocker.Storage.PostgresEvidenceThreadRepository(
|
||||
options.Database.ConnectionString,
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<NullEvidenceTimelinePublisher>();
|
||||
services.AddHttpClient<TimelineIndexerEvidenceTimelinePublisher>((provider, client) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Api;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
[Collection(EvidenceLockerTestCollection.Name)]
|
||||
public sealed class EvidenceAuditEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public EvidenceAuditEndpointsTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceHomeAndPacks_AreDeterministic()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var homeResponse = await _client.GetAsync("/api/v1/evidence", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, homeResponse.StatusCode);
|
||||
var home = await homeResponse.Content.ReadFromJsonAsync<EvidenceHomeDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(home);
|
||||
Assert.True(home!.QuickStats.LatestPacks24h > 0);
|
||||
|
||||
var firstPacksResponse = await _client.GetAsync("/api/v1/evidence/packs", TestContext.Current.CancellationToken);
|
||||
var secondPacksResponse = await _client.GetAsync("/api/v1/evidence/packs", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, firstPacksResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondPacksResponse.StatusCode);
|
||||
|
||||
var first = await firstPacksResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
var second = await secondPacksResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
var payload = await firstPacksResponse.Content.ReadFromJsonAsync<EvidencePackListResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotEmpty(payload!.Items);
|
||||
Assert.Equal("pack-9001", payload.Items[0].PackId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceAuditRoutes_ReturnExpectedPayloads()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var packDetail = await _client.GetFromJsonAsync<EvidencePackDetailDto>(
|
||||
"/api/v1/evidence/packs/pack-9001",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(packDetail);
|
||||
Assert.Equal("chain-9912", packDetail!.ProofChainId);
|
||||
|
||||
var proof = await _client.GetFromJsonAsync<ProofChainDetailDto>(
|
||||
"/api/v1/evidence/proofs/sha256:beef000000000000000000000000000000000000000000000000000000000003",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(proof);
|
||||
Assert.Equal("valid", proof!.Status);
|
||||
|
||||
var audit = await _client.GetFromJsonAsync<EvidenceAuditResponseDto>(
|
||||
"/api/v1/evidence/audit",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(audit);
|
||||
Assert.True(audit!.Total >= 1);
|
||||
|
||||
var receipt = await _client.GetFromJsonAsync<CvssReceiptDto>(
|
||||
"/api/v1/evidence/receipts/cvss/CVE-2026-1234",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal(9.8m, receipt!.BaseScore);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceAuditRoutes_UnknownResources_ReturnNotFound()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var packResponse = await _client.GetAsync("/api/v1/evidence/packs/missing-pack", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, packResponse.StatusCode);
|
||||
|
||||
var proofResponse = await _client.GetAsync("/api/v1/evidence/proofs/sha256:missing", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, proofResponse.StatusCode);
|
||||
|
||||
var receiptResponse = await _client.GetAsync("/api/v1/evidence/receipts/cvss/CVE-0000-0000", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, receiptResponse.StatusCode);
|
||||
}
|
||||
|
||||
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
|
||||
{
|
||||
client.DefaultRequestHeaders.Clear();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,12 @@ app.MapExportEndpoints();
|
||||
// Verdict attestation endpoints
|
||||
app.MapVerdictEndpoints();
|
||||
|
||||
// Evidence & audit adapter endpoints (Pack v2)
|
||||
app.MapEvidenceAuditEndpoints();
|
||||
|
||||
// Evidence Thread endpoints (Artifact Canonical Record API)
|
||||
app.MapEvidenceThreadEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.EvidenceLocker.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for querying the Artifact Canonical Record materialized view.
|
||||
/// Sprint: SPRINT_20260219_009 (CID-04)
|
||||
/// </summary>
|
||||
public interface IEvidenceThreadRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an artifact canonical record by canonical_id (sha256 hex).
|
||||
/// </summary>
|
||||
Task<ArtifactCanonicalRecord?> GetByCanonicalIdAsync(
|
||||
string canonicalId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a PURL to artifact canonical records.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ArtifactCanonicalRecord>> GetByPurlAsync(
|
||||
string purl,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row from proofchain.artifact_canonical_records materialized view.
|
||||
/// </summary>
|
||||
public sealed record ArtifactCanonicalRecord
|
||||
{
|
||||
public required string CanonicalId { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public string? ArtifactDigest { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated attestations as JSONB string from the materialized view.
|
||||
/// </summary>
|
||||
public required string Attestations { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEvidenceThreadRepository"/>.
|
||||
/// Reads from the proofchain.artifact_canonical_records materialized view.
|
||||
/// Sprint: SPRINT_20260219_009 (CID-04)
|
||||
/// </summary>
|
||||
public sealed class PostgresEvidenceThreadRepository : IEvidenceThreadRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<PostgresEvidenceThreadRepository> _logger;
|
||||
|
||||
public PostgresEvidenceThreadRepository(
|
||||
string connectionString,
|
||||
ILogger<PostgresEvidenceThreadRepository> logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ArtifactCanonicalRecord?> GetByCanonicalIdAsync(
|
||||
string canonicalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(canonicalId))
|
||||
{
|
||||
throw new ArgumentException("Canonical ID cannot be null or whitespace.", nameof(canonicalId));
|
||||
}
|
||||
|
||||
const string sql = @"
|
||||
SELECT
|
||||
canonical_id AS CanonicalId,
|
||||
format AS Format,
|
||||
artifact_digest AS ArtifactDigest,
|
||||
purl AS Purl,
|
||||
created_at AS CreatedAt,
|
||||
attestations::text AS Attestations
|
||||
FROM proofchain.artifact_canonical_records
|
||||
WHERE canonical_id = @CanonicalId;
|
||||
";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var record = await connection.QuerySingleOrDefaultAsync<ArtifactCanonicalRecord>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { CanonicalId = canonicalId },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return record;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve artifact canonical record for {CanonicalId}", canonicalId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ArtifactCanonicalRecord>> GetByPurlAsync(
|
||||
string purl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
throw new ArgumentException("PURL cannot be null or whitespace.", nameof(purl));
|
||||
}
|
||||
|
||||
const string sql = @"
|
||||
SELECT
|
||||
canonical_id AS CanonicalId,
|
||||
format AS Format,
|
||||
artifact_digest AS ArtifactDigest,
|
||||
purl AS Purl,
|
||||
created_at AS CreatedAt,
|
||||
attestations::text AS Attestations
|
||||
FROM proofchain.artifact_canonical_records
|
||||
WHERE purl = @Purl
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var results = await connection.QueryAsync<ArtifactCanonicalRecord>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { Purl = purl },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return results.AsList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve artifact canonical records for PURL {Purl}", purl);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,18 @@ public static class IntegrationEndpoints
|
||||
.WithName("CheckIntegrationHealth")
|
||||
.WithDescription("Performs a health check on an integration.");
|
||||
|
||||
// Impact map
|
||||
group.MapGet("/{id:guid}/impact", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.GetImpactAsync(id, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("GetIntegrationImpact")
|
||||
.WithDescription("Returns affected workflows and severity impact for an integration.");
|
||||
|
||||
// Get supported providers
|
||||
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
|
||||
{
|
||||
|
||||
@@ -269,6 +269,31 @@ public sealed class IntegrationService
|
||||
result.Duration);
|
||||
}
|
||||
|
||||
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var impactedWorkflows = BuildImpactedWorkflows(integration)
|
||||
.OrderBy(workflow => workflow.Workflow, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var blockingCount = impactedWorkflows.Count(workflow => workflow.Blocking);
|
||||
|
||||
return new IntegrationImpactResponse(
|
||||
IntegrationId: integration.Id,
|
||||
IntegrationName: integration.Name,
|
||||
Type: integration.Type,
|
||||
Provider: integration.Provider,
|
||||
Status: integration.Status,
|
||||
Severity: DetermineSeverity(integration.Status, blockingCount),
|
||||
BlockingWorkflowCount: blockingCount,
|
||||
TotalWorkflowCount: impactedWorkflows.Count,
|
||||
ImpactedWorkflows: impactedWorkflows);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
|
||||
{
|
||||
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
|
||||
@@ -277,6 +302,55 @@ public sealed class IntegrationService
|
||||
p.Provider)).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ImpactedWorkflow> BuildImpactedWorkflows(Integration integration)
|
||||
{
|
||||
var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived;
|
||||
return integration.Type switch
|
||||
{
|
||||
IntegrationType.Registry =>
|
||||
[
|
||||
new ImpactedWorkflow("bundle-materialization", "release-control", blockedByStatus, "Container digest fetch and verification path.", "restore-registry-connectivity"),
|
||||
new ImpactedWorkflow("sbom-attachment", "evidence", blockedByStatus, "SBOM/image digest correlation during pack generation.", "re-run-bundle-sync"),
|
||||
],
|
||||
IntegrationType.Scm =>
|
||||
[
|
||||
new ImpactedWorkflow("bundle-changelog", "release-control", blockedByStatus, "Repository changelog enrichment for bundle versions.", "reconnect-scm-app"),
|
||||
new ImpactedWorkflow("policy-drift-audit", "administration", blockedByStatus, "Policy governance change audit extraction.", "refresh-scm-access-token"),
|
||||
],
|
||||
IntegrationType.CiCd =>
|
||||
[
|
||||
new ImpactedWorkflow("promotion-preflight", "release-control", blockedByStatus, "Deployment signal and gate preflight signal stream.", "revalidate-ci-runner-credentials"),
|
||||
new ImpactedWorkflow("ops-job-health", "platform-ops", blockedByStatus, "Pipeline lag/health insights for nightly report.", "replay-latest-ci-webhooks"),
|
||||
],
|
||||
IntegrationType.RepoSource =>
|
||||
[
|
||||
new ImpactedWorkflow("dependency-resolution", "security-risk", blockedByStatus, "Package advisory resolution and normalization.", "verify-repository-mirror"),
|
||||
new ImpactedWorkflow("hot-lookup-projection", "security-risk", blockedByStatus, "Hot-lookup enrichment for findings explorer.", "resync-package-index"),
|
||||
],
|
||||
IntegrationType.RuntimeHost =>
|
||||
[
|
||||
new ImpactedWorkflow("runtime-reachability", "security-risk", blockedByStatus, "Runtime witness ingestion for reachability confidence.", "restart-runtime-agent"),
|
||||
new ImpactedWorkflow("ops-confidence", "platform-ops", blockedByStatus, "Data-confidence score for approvals and dashboard.", "clear-runtime-dlq"),
|
||||
],
|
||||
IntegrationType.FeedMirror =>
|
||||
[
|
||||
new ImpactedWorkflow("advisory-freshness", "security-risk", blockedByStatus, "Advisory source freshness and conflict views.", "refresh-feed-mirror"),
|
||||
new ImpactedWorkflow("rescore-pipeline", "platform-ops", blockedByStatus, "Nightly rescoring jobs and stale SBOM remediation.", "trigger-feed-replay"),
|
||||
],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineSeverity(IntegrationStatus status, int blockingCount)
|
||||
{
|
||||
if (status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived)
|
||||
{
|
||||
return "high";
|
||||
}
|
||||
|
||||
return blockingCount > 0 ? "medium" : "low";
|
||||
}
|
||||
|
||||
private static IntegrationConfig BuildConfig(Integration integration, string? resolvedSecret)
|
||||
{
|
||||
IReadOnlyDictionary<string, object>? extendedConfig = null;
|
||||
@@ -321,3 +395,21 @@ public sealed class IntegrationService
|
||||
/// Information about a supported provider.
|
||||
/// </summary>
|
||||
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);
|
||||
|
||||
public sealed record IntegrationImpactResponse(
|
||||
Guid IntegrationId,
|
||||
string IntegrationName,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
IntegrationStatus Status,
|
||||
string Severity,
|
||||
int BlockingWorkflowCount,
|
||||
int TotalWorkflowCount,
|
||||
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
|
||||
|
||||
public sealed record ImpactedWorkflow(
|
||||
string Workflow,
|
||||
string Domain,
|
||||
bool Blocking,
|
||||
string Impact,
|
||||
string RecommendedAction);
|
||||
|
||||
@@ -21,7 +21,13 @@ public enum IntegrationType
|
||||
RuntimeHost = 5,
|
||||
|
||||
/// <summary>Advisory/vulnerability feed mirror.</summary>
|
||||
FeedMirror = 6
|
||||
FeedMirror = 6,
|
||||
|
||||
/// <summary>Symbol/debug pack source (Microsoft Symbols, debuginfod, partner feeds).</summary>
|
||||
SymbolSource = 7,
|
||||
|
||||
/// <summary>Remediation marketplace source (community, partner, vendor fix templates).</summary>
|
||||
Marketplace = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +81,18 @@ public enum IntegrationProvider
|
||||
NvdMirror = 601,
|
||||
OsvMirror = 602,
|
||||
|
||||
// Symbol sources
|
||||
MicrosoftSymbols = 700,
|
||||
UbuntuDebuginfod = 701,
|
||||
FedoraDebuginfod = 702,
|
||||
DebianDebuginfod = 703,
|
||||
PartnerSymbols = 704,
|
||||
|
||||
// Marketplace sources
|
||||
CommunityFixes = 800,
|
||||
PartnerFixes = 801,
|
||||
VendorFixes = 802,
|
||||
|
||||
// Generic / testing
|
||||
InMemory = 900,
|
||||
Custom = 999
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests;
|
||||
|
||||
public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationImpactWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public IntegrationImpactEndpointsTests(IntegrationImpactWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap()
|
||||
{
|
||||
var createRequest = new CreateIntegrationRequest(
|
||||
Name: $"NVD Mirror {Guid.NewGuid():N}",
|
||||
Description: "Feed mirror",
|
||||
Type: IntegrationType.FeedMirror,
|
||||
Provider: IntegrationProvider.NvdMirror,
|
||||
Endpoint: "https://feeds.example.local/nvd",
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: ["feed"]);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/api/v1/integrations/",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var first = await _client.GetFromJsonAsync<IntegrationImpactResponse>(
|
||||
$"/api/v1/integrations/{created!.Id}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
var second = await _client.GetFromJsonAsync<IntegrationImpactResponse>(
|
||||
$"/api/v1/integrations/{created.Id}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(first!.IntegrationId, second!.IntegrationId);
|
||||
Assert.Equal(first.IntegrationName, second.IntegrationName);
|
||||
Assert.Equal(first.Type, second.Type);
|
||||
Assert.Equal(first.Provider, second.Provider);
|
||||
Assert.Equal(first.Status, second.Status);
|
||||
Assert.Equal(first.Severity, second.Severity);
|
||||
Assert.Equal(first.BlockingWorkflowCount, second.BlockingWorkflowCount);
|
||||
Assert.Equal(first.TotalWorkflowCount, second.TotalWorkflowCount);
|
||||
Assert.Equal(
|
||||
first.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray(),
|
||||
second.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray());
|
||||
Assert.Equal("low", first!.Severity);
|
||||
Assert.Equal(0, first.BlockingWorkflowCount);
|
||||
|
||||
var ordered = first.ImpactedWorkflows
|
||||
.Select(workflow => workflow.Workflow)
|
||||
.OrderBy(workflow => workflow, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.Equal(ordered, first.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ImpactEndpoint_WithUnknownId_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/v1/integrations/{Guid.NewGuid()}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IIntegrationRepository>();
|
||||
services.AddSingleton<IIntegrationRepository, InMemoryIntegrationRepository>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, Integration> _items = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items.TryGetValue(id, out var value);
|
||||
return Task.FromResult<Integration?>(value is null ? null : Clone(value));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
IEnumerable<Integration> values = _items.Values;
|
||||
|
||||
if (!query.IncludeDeleted)
|
||||
{
|
||||
values = values.Where(item => !item.IsDeleted);
|
||||
}
|
||||
|
||||
if (query.Type.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Type == query.Type.Value);
|
||||
}
|
||||
|
||||
if (query.Provider.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Provider == query.Provider.Value);
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Status == query.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
values = values.Where(item => string.Equals(item.TenantId, query.TenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
values = values.Where(item =>
|
||||
item.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase) ||
|
||||
(item.Description?.Contains(query.Search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
}
|
||||
|
||||
values = values.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Skip(Math.Max(0, query.Skip))
|
||||
.Take(Math.Max(1, query.Take));
|
||||
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(values.Select(Clone).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return Task.FromResult(_items.Values.Count(item => query.IncludeDeleted || !item.IsDeleted));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items[integration.Id] = Clone(integration);
|
||||
return Task.FromResult(Clone(integration));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items[integration.Id] = Clone(integration);
|
||||
return Task.FromResult(Clone(integration));
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(id, out var existing))
|
||||
{
|
||||
existing.IsDeleted = true;
|
||||
existing.Status = IntegrationStatus.Archived;
|
||||
_items[id] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var items = _items.Values
|
||||
.Where(item => item.Provider == provider && !item.IsDeleted)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var items = _items.Values
|
||||
.Where(item => item.Type == type && item.Status == IntegrationStatus.Active && !item.IsDeleted)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(id, out var existing))
|
||||
{
|
||||
existing.LastHealthStatus = status;
|
||||
existing.LastHealthCheckAt = checkedAt;
|
||||
_items[id] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Integration Clone(Integration source)
|
||||
{
|
||||
return new Integration
|
||||
{
|
||||
Id = source.Id,
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
Type = source.Type,
|
||||
Provider = source.Provider,
|
||||
Status = source.Status,
|
||||
Endpoint = source.Endpoint,
|
||||
AuthRefUri = source.AuthRefUri,
|
||||
OrganizationId = source.OrganizationId,
|
||||
ConfigJson = source.ConfigJson,
|
||||
LastHealthStatus = source.LastHealthStatus,
|
||||
LastHealthCheckAt = source.LastHealthCheckAt,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = source.UpdatedAt,
|
||||
CreatedBy = source.CreatedBy,
|
||||
UpdatedBy = source.UpdatedBy,
|
||||
TenantId = source.TenantId,
|
||||
Tags = source.Tags.ToList(),
|
||||
IsDeleted = source.IsDeleted
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,50 @@ public class IntegrationServiceTests
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(id);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration(
|
||||
type: IntegrationType.FeedMirror,
|
||||
provider: IntegrationProvider.NvdMirror);
|
||||
integration.Status = IntegrationStatus.Failed;
|
||||
integration.Name = "NVD Mirror";
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(integration.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Severity.Should().Be("high");
|
||||
result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount);
|
||||
result.ImpactedWorkflows.Should().HaveCount(2);
|
||||
result.ImpactedWorkflows.Select(workflow => workflow.Workflow)
|
||||
.Should().BeInAscendingOrder(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static Integration CreateTestIntegration(
|
||||
IntegrationType type = IntegrationType.Registry,
|
||||
IntegrationProvider provider = IntegrationProvider.Harbor)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using StellaOps.Orchestrator.WebService.Endpoints;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.ControlPlane;
|
||||
|
||||
public sealed class ReleaseControlSignalCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalCatalog_KnownReleaseAndEnvironment_ReturnsDeterministicSignals()
|
||||
{
|
||||
var firstRisk = ReleaseControlSignalCatalog.GetRiskSnapshot("rel-002", "production");
|
||||
var secondRisk = ReleaseControlSignalCatalog.GetRiskSnapshot("rel-002", "production");
|
||||
var firstCoverage = ReleaseControlSignalCatalog.GetCoverage("rel-002");
|
||||
var secondCoverage = ReleaseControlSignalCatalog.GetCoverage("rel-002");
|
||||
var firstOps = ReleaseControlSignalCatalog.GetOpsConfidence("production");
|
||||
var secondOps = ReleaseControlSignalCatalog.GetOpsConfidence("production");
|
||||
|
||||
Assert.Equal(firstRisk, secondRisk);
|
||||
Assert.Equal(firstCoverage, secondCoverage);
|
||||
Assert.Equal(firstOps, secondOps);
|
||||
Assert.Equal("warning", firstOps.Status);
|
||||
Assert.True(firstRisk.CriticalReachable > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalCatalog_UnknownReleaseAndEnvironment_UsesStableFallbacks()
|
||||
{
|
||||
var risk = ReleaseControlSignalCatalog.GetRiskSnapshot("rel-unknown", "qa");
|
||||
var coverage = ReleaseControlSignalCatalog.GetCoverage("rel-unknown");
|
||||
var ops = ReleaseControlSignalCatalog.GetOpsConfidence("qa");
|
||||
|
||||
Assert.Equal("qa", risk.EnvironmentId);
|
||||
Assert.Equal(0, risk.CriticalReachable);
|
||||
Assert.Equal("clean", risk.Severity);
|
||||
|
||||
Assert.Equal(100, coverage.BuildCoveragePercent);
|
||||
Assert.Equal(100, coverage.ImageCoveragePercent);
|
||||
Assert.Equal(100, coverage.RuntimeCoveragePercent);
|
||||
|
||||
Assert.Equal("unknown", ops.Status);
|
||||
Assert.Equal(0, ops.TrustScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovalProjection_WithDerivedSignals_PopulatesContractFields()
|
||||
{
|
||||
var approval = new ApprovalEndpoints.ApprovalDto
|
||||
{
|
||||
Id = "apr-test",
|
||||
ReleaseId = "rel-002",
|
||||
ReleaseName = "Platform Release",
|
||||
ReleaseVersion = "1.3.0-rc1",
|
||||
SourceEnvironment = "staging",
|
||||
TargetEnvironment = "production",
|
||||
RequestedBy = "test-user",
|
||||
RequestedAt = "2026-02-19T03:10:00Z",
|
||||
Urgency = "high",
|
||||
Justification = "Contract projection test",
|
||||
Status = "pending",
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
GatesPassed = true,
|
||||
ExpiresAt = "2026-02-21T03:10:00Z",
|
||||
ReleaseComponents =
|
||||
[
|
||||
new ApprovalEndpoints.ReleaseComponentSummaryDto
|
||||
{
|
||||
Name = "api",
|
||||
Version = "1.3.0-rc1",
|
||||
Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var projected = ApprovalEndpoints.WithDerivedSignals(approval);
|
||||
var summary = ApprovalEndpoints.ToSummary(projected);
|
||||
|
||||
Assert.NotNull(projected.ManifestDigest);
|
||||
Assert.NotNull(projected.RiskSnapshot);
|
||||
Assert.NotNull(projected.ReachabilityCoverage);
|
||||
Assert.NotNull(projected.OpsConfidence);
|
||||
Assert.NotNull(projected.EvidencePacket);
|
||||
Assert.NotNull(projected.DecisionDigest);
|
||||
Assert.Equal(projected.DecisionDigest, projected.EvidencePacket!.DecisionDigest);
|
||||
|
||||
Assert.Equal(projected.ManifestDigest, summary.ManifestDigest);
|
||||
Assert.Equal(projected.RiskSnapshot, summary.RiskSnapshot);
|
||||
Assert.Equal(projected.OpsConfidence, summary.OpsConfidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using StellaOps.Orchestrator.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.ControlPlane;
|
||||
|
||||
public sealed class ReleaseControlV2EndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ApprovalRoutes_ReturnDeterministicPayloads()
|
||||
{
|
||||
await using var app = await CreateTestAppAsync();
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var first = await client.GetStringAsync("/api/v1/approvals", TestContext.Current.CancellationToken);
|
||||
var second = await client.GetStringAsync("/api/v1/approvals", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(first, second);
|
||||
using var queueDoc = JsonDocument.Parse(first);
|
||||
var queueItems = queueDoc.RootElement;
|
||||
Assert.True(queueItems.GetArrayLength() > 0);
|
||||
var queueFirst = queueItems[0];
|
||||
Assert.StartsWith("sha256:", queueFirst.GetProperty("manifestDigest").GetString(), StringComparison.Ordinal);
|
||||
Assert.Equal("warning", queueFirst.GetProperty("opsConfidence").GetProperty("status").GetString());
|
||||
|
||||
var routes = new[]
|
||||
{
|
||||
"/api/v1/approvals/apr-001",
|
||||
"/api/v1/approvals/apr-001/gates",
|
||||
"/api/v1/approvals/apr-001/evidence",
|
||||
"/api/v1/approvals/apr-001/security-snapshot",
|
||||
"/api/v1/approvals/apr-001/ops-health",
|
||||
};
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var response = await client.GetAsync(route, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
var detailPayload = await client.GetStringAsync("/api/v1/approvals/apr-001", TestContext.Current.CancellationToken);
|
||||
using var detailDoc = JsonDocument.Parse(detailPayload);
|
||||
var detail = detailDoc.RootElement;
|
||||
Assert.StartsWith("sha256:", detail.GetProperty("manifestDigest").GetString(), StringComparison.Ordinal);
|
||||
Assert.Equal("warning", detail.GetProperty("riskSnapshot").GetProperty("status").GetString());
|
||||
Assert.Equal("warning", detail.GetProperty("opsConfidence").GetProperty("status").GetString());
|
||||
Assert.True(detail.GetProperty("reachabilityCoverage").GetProperty("runtimeCoveragePercent").GetInt32() >= 0);
|
||||
Assert.StartsWith("sha256:", detail.GetProperty("decisionDigest").GetString(), StringComparison.Ordinal);
|
||||
|
||||
var gatesPayload = await client.GetStringAsync("/api/v1/approvals/apr-001/gates", TestContext.Current.CancellationToken);
|
||||
using var gatesDoc = JsonDocument.Parse(gatesPayload);
|
||||
var gateNames = gatesDoc.RootElement
|
||||
.GetProperty("gates")
|
||||
.EnumerateArray()
|
||||
.Select(gate => gate.GetProperty("gateName").GetString())
|
||||
.ToArray();
|
||||
var orderedGateNames = gateNames.OrderBy(name => name, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(orderedGateNames, gateNames);
|
||||
|
||||
var decision = await client.PostAsJsonAsync(
|
||||
"/api/v1/approvals/apr-001/decision",
|
||||
new { action = "approve", comment = "ack", actor = "tester" },
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, decision.StatusCode);
|
||||
var decisionBody = await decision.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var decisionDoc = JsonDocument.Parse(decisionBody);
|
||||
Assert.True(decisionDoc.RootElement.GetProperty("currentApprovals").GetInt32() >= 1);
|
||||
|
||||
var missing = await client.GetAsync("/api/v1/approvals/missing", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
|
||||
var missingDecision = await client.PostAsJsonAsync(
|
||||
"/api/v1/approvals/missing/decision",
|
||||
new { action = "approve", comment = "n/a", actor = "tester" },
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missingDecision.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunRoutes_ReturnTimelineAndRollbackContracts()
|
||||
{
|
||||
await using var app = await CreateTestAppAsync();
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var routes = new[]
|
||||
{
|
||||
"/api/v1/runs/run-001",
|
||||
"/api/v1/runs/run-001/steps",
|
||||
"/api/v1/runs/run-001/steps/step-01",
|
||||
};
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var response = await client.GetAsync(route, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
var runPayload = await client.GetStringAsync("/api/v1/runs/run-001", TestContext.Current.CancellationToken);
|
||||
using var runDoc = JsonDocument.Parse(runPayload);
|
||||
Assert.Equal("run-001", runDoc.RootElement.GetProperty("runId").GetString());
|
||||
var stepOrders = runDoc.RootElement
|
||||
.GetProperty("steps")
|
||||
.EnumerateArray()
|
||||
.Select(step => step.GetProperty("order").GetInt32())
|
||||
.ToArray();
|
||||
Assert.Equal(stepOrders.OrderBy(order => order).ToArray(), stepOrders);
|
||||
|
||||
var stepPayload = await client.GetStringAsync("/api/v1/runs/run-001/steps/step-01", TestContext.Current.CancellationToken);
|
||||
using var stepDoc = JsonDocument.Parse(stepPayload);
|
||||
Assert.StartsWith("/api/v1/evidence/thread/", stepDoc.RootElement.GetProperty("evidenceThreadLink").GetString(), StringComparison.Ordinal);
|
||||
Assert.StartsWith("/logs/", stepDoc.RootElement.GetProperty("logArtifactLink").GetString(), StringComparison.Ordinal);
|
||||
|
||||
var rollback = await client.PostAsJsonAsync(
|
||||
"/api/v1/runs/run-001/rollback",
|
||||
new { scope = "full", preview = true },
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, rollback.StatusCode);
|
||||
var rollbackPayload = await rollback.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var rollbackDoc = JsonDocument.Parse(rollbackPayload);
|
||||
Assert.Equal("run-001", rollbackDoc.RootElement.GetProperty("sourceRunId").GetString());
|
||||
Assert.Equal("queued", rollbackDoc.RootElement.GetProperty("status").GetString());
|
||||
Assert.True(rollbackDoc.RootElement.GetProperty("preview").GetBoolean());
|
||||
|
||||
var compat = await client.GetAsync("/v1/runs/run-001", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, compat.StatusCode);
|
||||
|
||||
var missing = await client.GetAsync("/api/v1/runs/missing", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnvironmentRoutes_ReturnStandardizedViews()
|
||||
{
|
||||
await using var app = await CreateTestAppAsync();
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var routes = new[]
|
||||
{
|
||||
"/api/v1/environments/env-production",
|
||||
"/api/v1/environments/env-production/deployments",
|
||||
"/api/v1/environments/env-production/security-snapshot",
|
||||
"/api/v1/environments/env-production/evidence",
|
||||
"/api/v1/environments/env-production/ops-health",
|
||||
};
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var response = await client.GetAsync(route, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
var detailPayload = await client.GetStringAsync("/api/v1/environments/env-production", TestContext.Current.CancellationToken);
|
||||
using var detailDoc = JsonDocument.Parse(detailPayload);
|
||||
var detail = detailDoc.RootElement;
|
||||
Assert.Equal("env-production", detail.GetProperty("environmentId").GetString());
|
||||
Assert.StartsWith("sha256:", detail.GetProperty("manifestDigest").GetString(), StringComparison.Ordinal);
|
||||
Assert.Equal("warning", detail.GetProperty("opsConfidence").GetProperty("status").GetString());
|
||||
|
||||
var deploymentsPayload = await client.GetStringAsync("/api/v1/environments/env-production/deployments", TestContext.Current.CancellationToken);
|
||||
using var deploymentsDoc = JsonDocument.Parse(deploymentsPayload);
|
||||
var deploymentTimes = deploymentsDoc.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetProperty("deployedAt").GetString())
|
||||
.ToArray();
|
||||
Assert.Equal(deploymentTimes.OrderByDescending(item => item, StringComparer.Ordinal).ToArray(), deploymentTimes);
|
||||
|
||||
var evidencePayload = await client.GetStringAsync("/api/v1/environments/env-production/evidence", TestContext.Current.CancellationToken);
|
||||
using var evidenceDoc = JsonDocument.Parse(evidencePayload);
|
||||
Assert.StartsWith("sha256:", evidenceDoc.RootElement.GetProperty("evidence").GetProperty("decisionDigest").GetString(), StringComparison.Ordinal);
|
||||
|
||||
var missing = await client.GetAsync("/api/v1/environments/missing", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> CreateTestAppAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapReleaseControlV2Endpoints();
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@
|
||||
|
||||
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
|
||||
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Orchestrator.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Risk snapshot surfaced in promotion/approval contracts (Pack 13/17).
|
||||
/// </summary>
|
||||
public sealed record PromotionRiskSnapshot(
|
||||
string EnvironmentId,
|
||||
int CriticalReachable,
|
||||
int HighReachable,
|
||||
int HighNotReachable,
|
||||
decimal VexCoveragePercent,
|
||||
string Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid reachability coverage (build/image/runtime) surfaced as confidence.
|
||||
/// </summary>
|
||||
public sealed record HybridReachabilityCoverage(
|
||||
int BuildCoveragePercent,
|
||||
int ImageCoveragePercent,
|
||||
int RuntimeCoveragePercent,
|
||||
int EvidenceAgeHours);
|
||||
|
||||
/// <summary>
|
||||
/// Operations/data confidence summary consumed by approvals and promotions.
|
||||
/// </summary>
|
||||
public sealed record OpsDataConfidence(
|
||||
string Status,
|
||||
string Summary,
|
||||
int TrustScore,
|
||||
DateTimeOffset DataAsOf,
|
||||
IReadOnlyList<string> Signals);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence packet summary for approval decision packets.
|
||||
/// </summary>
|
||||
public sealed record ApprovalEvidencePacket(
|
||||
string DecisionDigest,
|
||||
string PolicyDecisionDsse,
|
||||
string SbomSnapshotId,
|
||||
string ReachabilitySnapshotId,
|
||||
string DataIntegritySnapshotId);
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Orchestrator.WebService.Contracts;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
@@ -10,49 +12,72 @@ public static class ApprovalEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapApprovalEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/release-orchestrator/approvals")
|
||||
.WithTags("Approvals");
|
||||
|
||||
group.MapGet(string.Empty, ListApprovals)
|
||||
.WithName("Approval_List")
|
||||
.WithDescription("List approval requests with optional filtering");
|
||||
|
||||
group.MapGet("/{id}", GetApproval)
|
||||
.WithName("Approval_Get")
|
||||
.WithDescription("Get an approval by ID");
|
||||
|
||||
group.MapPost("/{id}/approve", Approve)
|
||||
.WithName("Approval_Approve")
|
||||
.WithDescription("Approve a pending approval request");
|
||||
|
||||
group.MapPost("/{id}/reject", Reject)
|
||||
.WithName("Approval_Reject")
|
||||
.WithDescription("Reject a pending approval request");
|
||||
|
||||
group.MapPost("/batch-approve", BatchApprove)
|
||||
.WithName("Approval_BatchApprove")
|
||||
.WithDescription("Batch approve multiple requests");
|
||||
|
||||
group.MapPost("/batch-reject", BatchReject)
|
||||
.WithName("Approval_BatchReject")
|
||||
.WithDescription("Batch reject multiple requests");
|
||||
MapApprovalGroup(app, "/api/release-orchestrator/approvals", includeRouteNames: true);
|
||||
MapApprovalGroup(app, "/api/v1/release-orchestrator/approvals", includeRouteNames: false);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapApprovalGroup(
|
||||
IEndpointRouteBuilder app,
|
||||
string prefix,
|
||||
bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("Approvals");
|
||||
|
||||
var list = group.MapGet(string.Empty, ListApprovals)
|
||||
.WithDescription("List approval requests with optional filtering");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
list.WithName("Approval_List");
|
||||
}
|
||||
|
||||
var detail = group.MapGet("/{id}", GetApproval)
|
||||
.WithDescription("Get an approval by ID");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
detail.WithName("Approval_Get");
|
||||
}
|
||||
|
||||
var approve = group.MapPost("/{id}/approve", Approve)
|
||||
.WithDescription("Approve a pending approval request");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
approve.WithName("Approval_Approve");
|
||||
}
|
||||
|
||||
var reject = group.MapPost("/{id}/reject", Reject)
|
||||
.WithDescription("Reject a pending approval request");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
reject.WithName("Approval_Reject");
|
||||
}
|
||||
|
||||
var batchApprove = group.MapPost("/batch-approve", BatchApprove)
|
||||
.WithDescription("Batch approve multiple requests");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
batchApprove.WithName("Approval_BatchApprove");
|
||||
}
|
||||
|
||||
var batchReject = group.MapPost("/batch-reject", BatchReject)
|
||||
.WithDescription("Batch reject multiple requests");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
batchReject.WithName("Approval_BatchReject");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ListApprovals(
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? urgencies,
|
||||
[FromQuery] string? environment)
|
||||
{
|
||||
var approvals = SeedData.Approvals.Select(a => new
|
||||
{
|
||||
a.Id, a.ReleaseId, a.ReleaseName, a.ReleaseVersion,
|
||||
a.SourceEnvironment, a.TargetEnvironment,
|
||||
a.RequestedBy, a.RequestedAt, a.Urgency, a.Justification,
|
||||
a.Status, a.CurrentApprovals, a.RequiredApprovals,
|
||||
a.GatesPassed, a.ScheduledTime, a.ExpiresAt,
|
||||
}).AsEnumerable();
|
||||
var approvals = SeedData.Approvals
|
||||
.Select(WithDerivedSignals)
|
||||
.Select(ToSummary)
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
@@ -78,7 +103,9 @@ public static class ApprovalEndpoints
|
||||
private static IResult GetApproval(string id)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
return approval is not null ? Results.Ok(approval) : Results.NotFound();
|
||||
return approval is not null
|
||||
? Results.Ok(WithDerivedSignals(approval))
|
||||
: Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult Approve(string id, [FromBody] ApprovalActionDto request)
|
||||
@@ -86,11 +113,11 @@ public static class ApprovalEndpoints
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(approval with
|
||||
return Results.Ok(WithDerivedSignals(approval with
|
||||
{
|
||||
CurrentApprovals = approval.CurrentApprovals + 1,
|
||||
Status = approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private static IResult Reject(string id, [FromBody] ApprovalActionDto request)
|
||||
@@ -98,7 +125,7 @@ public static class ApprovalEndpoints
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(approval with { Status = "rejected" });
|
||||
return Results.Ok(WithDerivedSignals(approval with { Status = "rejected" }));
|
||||
}
|
||||
|
||||
private static IResult BatchApprove([FromBody] BatchActionDto request)
|
||||
@@ -111,8 +138,91 @@ public static class ApprovalEndpoints
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
public static ApprovalDto WithDerivedSignals(ApprovalDto approval)
|
||||
{
|
||||
var manifestDigest = approval.ManifestDigest
|
||||
?? approval.ReleaseComponents.FirstOrDefault()?.Digest
|
||||
?? $"sha256:{approval.ReleaseId.Replace("-", string.Empty, StringComparison.Ordinal)}";
|
||||
|
||||
var risk = approval.RiskSnapshot
|
||||
?? ReleaseControlSignalCatalog.GetRiskSnapshot(approval.ReleaseId, approval.TargetEnvironment);
|
||||
|
||||
var coverage = approval.ReachabilityCoverage
|
||||
?? ReleaseControlSignalCatalog.GetCoverage(approval.ReleaseId);
|
||||
|
||||
var opsConfidence = approval.OpsConfidence
|
||||
?? ReleaseControlSignalCatalog.GetOpsConfidence(approval.TargetEnvironment);
|
||||
|
||||
var evidencePacket = approval.EvidencePacket
|
||||
?? ReleaseControlSignalCatalog.BuildEvidencePacket(approval.Id, approval.ReleaseId);
|
||||
|
||||
return approval with
|
||||
{
|
||||
ManifestDigest = manifestDigest,
|
||||
RiskSnapshot = risk,
|
||||
ReachabilityCoverage = coverage,
|
||||
OpsConfidence = opsConfidence,
|
||||
EvidencePacket = evidencePacket,
|
||||
DecisionDigest = approval.DecisionDigest ?? evidencePacket.DecisionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
public static ApprovalSummaryDto ToSummary(ApprovalDto approval)
|
||||
{
|
||||
var enriched = WithDerivedSignals(approval);
|
||||
return new ApprovalSummaryDto
|
||||
{
|
||||
Id = enriched.Id,
|
||||
ReleaseId = enriched.ReleaseId,
|
||||
ReleaseName = enriched.ReleaseName,
|
||||
ReleaseVersion = enriched.ReleaseVersion,
|
||||
SourceEnvironment = enriched.SourceEnvironment,
|
||||
TargetEnvironment = enriched.TargetEnvironment,
|
||||
RequestedBy = enriched.RequestedBy,
|
||||
RequestedAt = enriched.RequestedAt,
|
||||
Urgency = enriched.Urgency,
|
||||
Justification = enriched.Justification,
|
||||
Status = enriched.Status,
|
||||
CurrentApprovals = enriched.CurrentApprovals,
|
||||
RequiredApprovals = enriched.RequiredApprovals,
|
||||
GatesPassed = enriched.GatesPassed,
|
||||
ScheduledTime = enriched.ScheduledTime,
|
||||
ExpiresAt = enriched.ExpiresAt,
|
||||
ManifestDigest = enriched.ManifestDigest,
|
||||
RiskSnapshot = enriched.RiskSnapshot,
|
||||
ReachabilityCoverage = enriched.ReachabilityCoverage,
|
||||
OpsConfidence = enriched.OpsConfidence,
|
||||
DecisionDigest = enriched.DecisionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ApprovalSummaryDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string SourceEnvironment { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required string RequestedAt { get; init; }
|
||||
public required string Urgency { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public bool GatesPassed { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public PromotionRiskSnapshot? RiskSnapshot { get; init; }
|
||||
public HybridReachabilityCoverage? ReachabilityCoverage { get; init; }
|
||||
public OpsDataConfidence? OpsConfidence { get; init; }
|
||||
public string? DecisionDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
@@ -135,6 +245,12 @@ public static class ApprovalEndpoints
|
||||
public List<ApprovalActionRecordDto> Actions { get; init; } = new();
|
||||
public List<ApproverDto> Approvers { get; init; } = new();
|
||||
public List<ReleaseComponentSummaryDto> ReleaseComponents { get; init; } = new();
|
||||
public string? ManifestDigest { get; init; }
|
||||
public PromotionRiskSnapshot? RiskSnapshot { get; init; }
|
||||
public HybridReachabilityCoverage? ReachabilityCoverage { get; init; }
|
||||
public OpsDataConfidence? OpsConfidence { get; init; }
|
||||
public ApprovalEvidencePacket? EvidencePacket { get; init; }
|
||||
public string? DecisionDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GateResultDto
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Orchestrator.WebService.Contracts;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// v2 contract adapters for Pack-driven release control routes.
|
||||
/// </summary>
|
||||
public static class ReleaseControlV2Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseControlV2Endpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapApprovalsV2(app);
|
||||
MapRunsV2(app);
|
||||
MapEnvironmentsV2(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapApprovalsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
var approvals = app.MapGroup("/api/v1/approvals")
|
||||
.WithTags("Approvals v2");
|
||||
|
||||
approvals.MapGet(string.Empty, ListApprovals)
|
||||
.WithName("ApprovalsV2_List")
|
||||
.WithDescription("List v2 approval queue entries with digest/risk/ops confidence.");
|
||||
|
||||
approvals.MapGet("/{id}", GetApprovalDetail)
|
||||
.WithName("ApprovalsV2_Get")
|
||||
.WithDescription("Get v2 approval detail decision packet.");
|
||||
|
||||
approvals.MapGet("/{id}/gates", GetApprovalGates)
|
||||
.WithName("ApprovalsV2_Gates")
|
||||
.WithDescription("Get detailed gate trace for a v2 approval.");
|
||||
|
||||
approvals.MapGet("/{id}/evidence", GetApprovalEvidence)
|
||||
.WithName("ApprovalsV2_Evidence")
|
||||
.WithDescription("Get decision packet evidence references for a v2 approval.");
|
||||
|
||||
approvals.MapGet("/{id}/security-snapshot", GetApprovalSecuritySnapshot)
|
||||
.WithName("ApprovalsV2_SecuritySnapshot")
|
||||
.WithDescription("Get security snapshot (CritR/HighR/coverage) for approval context.");
|
||||
|
||||
approvals.MapGet("/{id}/ops-health", GetApprovalOpsHealth)
|
||||
.WithName("ApprovalsV2_OpsHealth")
|
||||
.WithDescription("Get data-integrity confidence that impacts approval defensibility.");
|
||||
|
||||
approvals.MapPost("/{id}/decision", PostApprovalDecision)
|
||||
.WithName("ApprovalsV2_Decision")
|
||||
.WithDescription("Apply a decision action (approve/reject/defer/escalate).");
|
||||
}
|
||||
|
||||
private static void MapRunsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
static void MapRunGroup(RouteGroupBuilder runs)
|
||||
{
|
||||
runs.MapGet("/{id}", GetRunDetail)
|
||||
.WithDescription("Get promotion run detail timeline.");
|
||||
|
||||
runs.MapGet("/{id}/steps", GetRunSteps)
|
||||
.WithDescription("Get checkpoint-level run step list.");
|
||||
|
||||
runs.MapGet("/{id}/steps/{stepId}", GetRunStepDetail)
|
||||
.WithDescription("Get run step details including logs and captured evidence.");
|
||||
|
||||
runs.MapPost("/{id}/rollback", TriggerRollback)
|
||||
.WithDescription("Trigger rollback with guard-state projection.");
|
||||
}
|
||||
|
||||
var apiRuns = app.MapGroup("/api/v1/runs")
|
||||
.WithTags("Runs v2");
|
||||
MapRunGroup(apiRuns);
|
||||
apiRuns.WithGroupName("runs-v2");
|
||||
|
||||
var legacyV1Runs = app.MapGroup("/v1/runs")
|
||||
.WithTags("Runs v2");
|
||||
MapRunGroup(legacyV1Runs);
|
||||
legacyV1Runs.WithGroupName("runs-v1-compat");
|
||||
}
|
||||
|
||||
private static void MapEnvironmentsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
var environments = app.MapGroup("/api/v1/environments")
|
||||
.WithTags("Environments v2");
|
||||
|
||||
environments.MapGet("/{id}", GetEnvironmentDetail)
|
||||
.WithName("EnvironmentsV2_Get")
|
||||
.WithDescription("Get standardized environment detail header.");
|
||||
|
||||
environments.MapGet("/{id}/deployments", GetEnvironmentDeployments)
|
||||
.WithName("EnvironmentsV2_Deployments")
|
||||
.WithDescription("Get deployment history scoped to an environment.");
|
||||
|
||||
environments.MapGet("/{id}/security-snapshot", GetEnvironmentSecuritySnapshot)
|
||||
.WithName("EnvironmentsV2_SecuritySnapshot")
|
||||
.WithDescription("Get environment-level security snapshot and top risks.");
|
||||
|
||||
environments.MapGet("/{id}/evidence", GetEnvironmentEvidence)
|
||||
.WithName("EnvironmentsV2_Evidence")
|
||||
.WithDescription("Get environment evidence snapshot/export references.");
|
||||
|
||||
environments.MapGet("/{id}/ops-health", GetEnvironmentOpsHealth)
|
||||
.WithName("EnvironmentsV2_OpsHealth")
|
||||
.WithDescription("Get environment data-confidence and relevant ops signals.");
|
||||
}
|
||||
|
||||
private static IResult ListApprovals(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? targetEnvironment)
|
||||
{
|
||||
var rows = ApprovalEndpoints.SeedData.Approvals
|
||||
.Select(ApprovalEndpoints.WithDerivedSignals)
|
||||
.Select(ApprovalEndpoints.ToSummary)
|
||||
.OrderByDescending(row => row.RequestedAt, StringComparer.Ordinal)
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
rows = rows.Where(row => string.Equals(row.Status, status, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(targetEnvironment))
|
||||
{
|
||||
rows = rows.Where(row => string.Equals(row.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Results.Ok(rows.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetApprovalDetail(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(approval);
|
||||
}
|
||||
|
||||
private static IResult GetApprovalGates(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
decisionDigest = approval.DecisionDigest,
|
||||
gates = approval.GateResults.OrderBy(g => g.GateName, StringComparer.Ordinal).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalEvidence(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
packet = approval.EvidencePacket,
|
||||
manifestDigest = approval.ManifestDigest,
|
||||
decisionDigest = approval.DecisionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalSecuritySnapshot(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
manifestDigest = approval.ManifestDigest,
|
||||
risk = approval.RiskSnapshot,
|
||||
reachability = approval.ReachabilityCoverage,
|
||||
topFindings = BuildTopFindings(approval),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalOpsHealth(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
opsConfidence = approval.OpsConfidence,
|
||||
impactedJobs = BuildImpactedJobs(approval.TargetEnvironment),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PostApprovalDecision(string id, [FromBody] ApprovalDecisionRequest request)
|
||||
{
|
||||
var idx = ApprovalEndpoints.SeedData.Approvals.FindIndex(approval =>
|
||||
string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx < 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var approval = ApprovalEndpoints.WithDerivedSignals(ApprovalEndpoints.SeedData.Approvals[idx]);
|
||||
var normalizedAction = (request.Action ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var actor = string.IsNullOrWhiteSpace(request.Actor) ? "release-manager" : request.Actor.Trim();
|
||||
var timestamp = DateTimeOffset.Parse("2026-02-19T03:20:00Z").ToString("O");
|
||||
|
||||
var nextStatus = normalizedAction switch
|
||||
{
|
||||
"approve" => approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status,
|
||||
"reject" => "rejected",
|
||||
"defer" => "pending",
|
||||
"escalate" => "pending",
|
||||
_ => approval.Status,
|
||||
};
|
||||
|
||||
var updated = approval with
|
||||
{
|
||||
Status = nextStatus,
|
||||
CurrentApprovals = normalizedAction == "approve"
|
||||
? Math.Min(approval.RequiredApprovals, approval.CurrentApprovals + 1)
|
||||
: approval.CurrentApprovals,
|
||||
Actions = approval.Actions
|
||||
.Concat(new[]
|
||||
{
|
||||
new ApprovalEndpoints.ApprovalActionRecordDto
|
||||
{
|
||||
Id = $"act-{approval.Actions.Count + 1}",
|
||||
ApprovalId = approval.Id,
|
||||
Action = normalizedAction is "approve" or "reject" ? normalizedAction : "comment",
|
||||
Actor = actor,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment)
|
||||
? $"Decision action: {normalizedAction}"
|
||||
: request.Comment.Trim(),
|
||||
Timestamp = timestamp,
|
||||
},
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
ApprovalEndpoints.SeedData.Approvals[idx] = updated;
|
||||
return Results.Ok(ApprovalEndpoints.WithDerivedSignals(updated));
|
||||
}
|
||||
|
||||
private static IResult GetRunDetail(string id)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(run with
|
||||
{
|
||||
Steps = run.Steps.OrderBy(step => step.Order).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetRunSteps(string id)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(run.Steps.OrderBy(step => step.Order).ToList());
|
||||
}
|
||||
|
||||
private static IResult GetRunStepDetail(string id, string stepId)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var step = run.Steps.FirstOrDefault(item => string.Equals(item.StepId, stepId, StringComparison.OrdinalIgnoreCase));
|
||||
if (step is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(step);
|
||||
}
|
||||
|
||||
private static IResult TriggerRollback(string id, [FromBody] RollbackRequest? request)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rollbackAllowed = string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(run.Status, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(run.Status, "degraded", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!rollbackAllowed)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = "rollback_guard_blocked",
|
||||
reason = "Rollback is only allowed when run status is failed/warning/degraded.",
|
||||
});
|
||||
}
|
||||
|
||||
var rollbackRunId = $"rb-{id}";
|
||||
return Results.Accepted($"/api/v1/runs/{rollbackRunId}", new
|
||||
{
|
||||
rollbackRunId,
|
||||
sourceRunId = id,
|
||||
scope = request?.Scope ?? "full",
|
||||
status = "queued",
|
||||
requestedAt = "2026-02-19T03:22:00Z",
|
||||
preview = request?.Preview ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentDetail(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(env);
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentDeployments(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(env.RecentDeployments.OrderByDescending(item => item.DeployedAt).ToList());
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentSecuritySnapshot(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
manifestDigest = env.ManifestDigest,
|
||||
risk = env.RiskSnapshot,
|
||||
reachability = env.ReachabilityCoverage,
|
||||
sbomStatus = env.SbomStatus,
|
||||
topFindings = env.TopFindings,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentEvidence(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
evidence = env.Evidence,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentOpsHealth(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
opsConfidence = env.OpsConfidence,
|
||||
impactedJobs = BuildImpactedJobs(env.EnvironmentName),
|
||||
});
|
||||
}
|
||||
|
||||
private static ApprovalEndpoints.ApprovalDto? FindApproval(string id)
|
||||
{
|
||||
var approval = ApprovalEndpoints.SeedData.Approvals
|
||||
.FirstOrDefault(item => string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
return approval is null ? null : ApprovalEndpoints.WithDerivedSignals(approval);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildTopFindings(ApprovalEndpoints.ApprovalDto approval)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cve = "CVE-2026-1234",
|
||||
component = approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component",
|
||||
severity = "critical",
|
||||
reachability = "reachable",
|
||||
},
|
||||
new
|
||||
{
|
||||
cve = "CVE-2026-2088",
|
||||
component = approval.ReleaseComponents.Skip(1).FirstOrDefault()?.Name ?? approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component",
|
||||
severity = "high",
|
||||
reachability = "not_reachable",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildImpactedJobs(string targetEnvironment)
|
||||
{
|
||||
var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment);
|
||||
return ops.Signals
|
||||
.Select((signal, index) => new
|
||||
{
|
||||
job = $"ops-job-{index + 1}",
|
||||
signal,
|
||||
status = ops.Status,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, RunDetailDto> RunCatalog =
|
||||
new Dictionary<string, RunDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["run-001"] = new(
|
||||
RunId: "run-001",
|
||||
ReleaseId: "rel-002",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002",
|
||||
Status: "warning",
|
||||
StartedAt: "2026-02-19T02:10:00Z",
|
||||
CompletedAt: "2026-02-19T02:19:00Z",
|
||||
RollbackGuard: "armed",
|
||||
Steps:
|
||||
[
|
||||
new RunStepDto("step-01", 1, "Materialize Inputs", "passed", "2026-02-19T02:10:00Z", "2026-02-19T02:11:00Z", "/api/v1/evidence/thread/sha256-materialize", "/logs/run-001/step-01.log"),
|
||||
new RunStepDto("step-02", 2, "Policy Evaluation", "passed", "2026-02-19T02:11:00Z", "2026-02-19T02:13:00Z", "/api/v1/evidence/thread/sha256-policy", "/logs/run-001/step-02.log"),
|
||||
new RunStepDto("step-03", 3, "Deploy Stage", "warning", "2026-02-19T02:13:00Z", "2026-02-19T02:19:00Z", "/api/v1/evidence/thread/sha256-deploy", "/logs/run-001/step-03.log"),
|
||||
]),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, EnvironmentDetailDto> EnvironmentCatalog =
|
||||
new Dictionary<string, EnvironmentDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["env-production"] = new(
|
||||
EnvironmentId: "env-production",
|
||||
EnvironmentName: "production",
|
||||
Region: "us-east",
|
||||
DeployStatus: "degraded",
|
||||
SbomStatus: "stale",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002",
|
||||
RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-002", "production"),
|
||||
ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-002"),
|
||||
OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("production"),
|
||||
TopFindings:
|
||||
[
|
||||
"CVE-2026-1234 reachable in user-service",
|
||||
"Runtime ingest lag reduces confidence to WARN",
|
||||
],
|
||||
RecentDeployments:
|
||||
[
|
||||
new EnvironmentDeploymentDto("run-001", "rel-002", "1.3.0-rc1", "warning", "2026-02-19T02:19:00Z"),
|
||||
new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"),
|
||||
],
|
||||
Evidence: new EnvironmentEvidenceDto(
|
||||
"env-snapshot-production-20260219",
|
||||
"sha256:evidence-production-20260219",
|
||||
"/api/v1/evidence/thread/sha256:evidence-production-20260219")),
|
||||
["env-staging"] = new(
|
||||
EnvironmentId: "env-staging",
|
||||
EnvironmentName: "staging",
|
||||
Region: "us-east",
|
||||
DeployStatus: "healthy",
|
||||
SbomStatus: "fresh",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000001",
|
||||
RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-001", "staging"),
|
||||
ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-001"),
|
||||
OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("staging"),
|
||||
TopFindings:
|
||||
[
|
||||
"No critical reachable findings.",
|
||||
],
|
||||
RecentDeployments:
|
||||
[
|
||||
new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"),
|
||||
],
|
||||
Evidence: new EnvironmentEvidenceDto(
|
||||
"env-snapshot-staging-20260219",
|
||||
"sha256:evidence-staging-20260219",
|
||||
"/api/v1/evidence/thread/sha256:evidence-staging-20260219")),
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ApprovalDecisionRequest(string Action, string? Comment, string? Actor);
|
||||
|
||||
public sealed record RollbackRequest(string? Scope, bool? Preview);
|
||||
|
||||
public sealed record RunDetailDto(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ManifestDigest,
|
||||
string Status,
|
||||
string StartedAt,
|
||||
string CompletedAt,
|
||||
string RollbackGuard,
|
||||
IReadOnlyList<RunStepDto> Steps);
|
||||
|
||||
public sealed record RunStepDto(
|
||||
string StepId,
|
||||
int Order,
|
||||
string Name,
|
||||
string Status,
|
||||
string StartedAt,
|
||||
string CompletedAt,
|
||||
string EvidenceThreadLink,
|
||||
string LogArtifactLink);
|
||||
|
||||
public sealed record EnvironmentDetailDto(
|
||||
string EnvironmentId,
|
||||
string EnvironmentName,
|
||||
string Region,
|
||||
string DeployStatus,
|
||||
string SbomStatus,
|
||||
string ManifestDigest,
|
||||
PromotionRiskSnapshot RiskSnapshot,
|
||||
HybridReachabilityCoverage ReachabilityCoverage,
|
||||
OpsDataConfidence OpsConfidence,
|
||||
IReadOnlyList<string> TopFindings,
|
||||
IReadOnlyList<EnvironmentDeploymentDto> RecentDeployments,
|
||||
EnvironmentEvidenceDto Evidence);
|
||||
|
||||
public sealed record EnvironmentDeploymentDto(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ReleaseVersion,
|
||||
string Status,
|
||||
string DeployedAt);
|
||||
|
||||
public sealed record EnvironmentEvidenceDto(
|
||||
string SnapshotId,
|
||||
string DecisionDigest,
|
||||
string ThreadLink);
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
@@ -9,86 +10,144 @@ namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
/// </summary>
|
||||
public static class ReleaseEndpoints
|
||||
{
|
||||
private static readonly DateTimeOffset PreviewEvaluatedAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
|
||||
|
||||
public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/release-orchestrator/releases")
|
||||
.WithTags("Releases");
|
||||
|
||||
group.MapGet(string.Empty, ListReleases)
|
||||
.WithName("Release_List")
|
||||
.WithDescription("List releases with optional filtering");
|
||||
|
||||
group.MapGet("/{id}", GetRelease)
|
||||
.WithName("Release_Get")
|
||||
.WithDescription("Get a release by ID");
|
||||
|
||||
group.MapPost(string.Empty, CreateRelease)
|
||||
.WithName("Release_Create")
|
||||
.WithDescription("Create a new release");
|
||||
|
||||
group.MapPatch("/{id}", UpdateRelease)
|
||||
.WithName("Release_Update")
|
||||
.WithDescription("Update an existing release");
|
||||
|
||||
group.MapDelete("/{id}", DeleteRelease)
|
||||
.WithName("Release_Delete")
|
||||
.WithDescription("Delete a release");
|
||||
|
||||
// Lifecycle
|
||||
group.MapPost("/{id}/ready", MarkReady)
|
||||
.WithName("Release_MarkReady")
|
||||
.WithDescription("Mark a release as ready for promotion");
|
||||
|
||||
group.MapPost("/{id}/promote", RequestPromotion)
|
||||
.WithName("Release_Promote")
|
||||
.WithDescription("Request promotion to target environment");
|
||||
|
||||
group.MapPost("/{id}/deploy", Deploy)
|
||||
.WithName("Release_Deploy")
|
||||
.WithDescription("Deploy a release");
|
||||
|
||||
group.MapPost("/{id}/rollback", Rollback)
|
||||
.WithName("Release_Rollback")
|
||||
.WithDescription("Rollback a deployed release");
|
||||
|
||||
group.MapPost("/{id}/clone", CloneRelease)
|
||||
.WithName("Release_Clone")
|
||||
.WithDescription("Clone a release with new name and version");
|
||||
|
||||
// Components
|
||||
group.MapGet("/{releaseId}/components", GetComponents)
|
||||
.WithName("Release_GetComponents")
|
||||
.WithDescription("Get components for a release");
|
||||
|
||||
group.MapPost("/{releaseId}/components", AddComponent)
|
||||
.WithName("Release_AddComponent")
|
||||
.WithDescription("Add a component to a release");
|
||||
|
||||
group.MapPatch("/{releaseId}/components/{componentId}", UpdateComponent)
|
||||
.WithName("Release_UpdateComponent")
|
||||
.WithDescription("Update a release component");
|
||||
|
||||
group.MapDelete("/{releaseId}/components/{componentId}", RemoveComponent)
|
||||
.WithName("Release_RemoveComponent")
|
||||
.WithDescription("Remove a component from a release");
|
||||
|
||||
// Events
|
||||
group.MapGet("/{releaseId}/events", GetEvents)
|
||||
.WithName("Release_GetEvents")
|
||||
.WithDescription("Get events for a release");
|
||||
|
||||
// Promotion preview
|
||||
group.MapGet("/{releaseId}/promotion-preview", GetPromotionPreview)
|
||||
.WithName("Release_PromotionPreview")
|
||||
.WithDescription("Get promotion preview with gate results");
|
||||
|
||||
group.MapGet("/{releaseId}/available-environments", GetAvailableEnvironments)
|
||||
.WithName("Release_AvailableEnvironments")
|
||||
.WithDescription("Get available target environments for promotion");
|
||||
MapReleaseGroup(app, "/api/release-orchestrator/releases", includeRouteNames: true);
|
||||
MapReleaseGroup(app, "/api/v1/release-orchestrator/releases", includeRouteNames: false);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapReleaseGroup(
|
||||
IEndpointRouteBuilder app,
|
||||
string prefix,
|
||||
bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("Releases");
|
||||
|
||||
var list = group.MapGet(string.Empty, ListReleases)
|
||||
.WithDescription("List releases with optional filtering");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
list.WithName("Release_List");
|
||||
}
|
||||
|
||||
var detail = group.MapGet("/{id}", GetRelease)
|
||||
.WithDescription("Get a release by ID");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
detail.WithName("Release_Get");
|
||||
}
|
||||
|
||||
var create = group.MapPost(string.Empty, CreateRelease)
|
||||
.WithDescription("Create a new release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
create.WithName("Release_Create");
|
||||
}
|
||||
|
||||
var update = group.MapPatch("/{id}", UpdateRelease)
|
||||
.WithDescription("Update an existing release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
update.WithName("Release_Update");
|
||||
}
|
||||
|
||||
var remove = group.MapDelete("/{id}", DeleteRelease)
|
||||
.WithDescription("Delete a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
remove.WithName("Release_Delete");
|
||||
}
|
||||
|
||||
var ready = group.MapPost("/{id}/ready", MarkReady)
|
||||
.WithDescription("Mark a release as ready for promotion");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
ready.WithName("Release_MarkReady");
|
||||
}
|
||||
|
||||
var promote = group.MapPost("/{id}/promote", RequestPromotion)
|
||||
.WithDescription("Request promotion to target environment");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
promote.WithName("Release_Promote");
|
||||
}
|
||||
|
||||
var deploy = group.MapPost("/{id}/deploy", Deploy)
|
||||
.WithDescription("Deploy a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
deploy.WithName("Release_Deploy");
|
||||
}
|
||||
|
||||
var rollback = group.MapPost("/{id}/rollback", Rollback)
|
||||
.WithDescription("Rollback a deployed release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
rollback.WithName("Release_Rollback");
|
||||
}
|
||||
|
||||
var clone = group.MapPost("/{id}/clone", CloneRelease)
|
||||
.WithDescription("Clone a release with new name and version");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
clone.WithName("Release_Clone");
|
||||
}
|
||||
|
||||
var components = group.MapGet("/{releaseId}/components", GetComponents)
|
||||
.WithDescription("Get components for a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
components.WithName("Release_GetComponents");
|
||||
}
|
||||
|
||||
var addComponent = group.MapPost("/{releaseId}/components", AddComponent)
|
||||
.WithDescription("Add a component to a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
addComponent.WithName("Release_AddComponent");
|
||||
}
|
||||
|
||||
var updateComponent = group.MapPatch("/{releaseId}/components/{componentId}", UpdateComponent)
|
||||
.WithDescription("Update a release component");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
updateComponent.WithName("Release_UpdateComponent");
|
||||
}
|
||||
|
||||
var removeComponent = group.MapDelete("/{releaseId}/components/{componentId}", RemoveComponent)
|
||||
.WithDescription("Remove a component from a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
removeComponent.WithName("Release_RemoveComponent");
|
||||
}
|
||||
|
||||
var events = group.MapGet("/{releaseId}/events", GetEvents)
|
||||
.WithDescription("Get events for a release");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
events.WithName("Release_GetEvents");
|
||||
}
|
||||
|
||||
var preview = group.MapGet("/{releaseId}/promotion-preview", GetPromotionPreview)
|
||||
.WithDescription("Get promotion preview with gate results");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
preview.WithName("Release_PromotionPreview");
|
||||
}
|
||||
|
||||
var targets = group.MapGet("/{releaseId}/available-environments", GetAvailableEnvironments)
|
||||
.WithDescription("Get available target environments for promotion");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
targets.WithName("Release_AvailableEnvironments");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
private static IResult ListReleases(
|
||||
@@ -206,11 +265,77 @@ public static class ReleaseEndpoints
|
||||
return Results.Ok(release with { Status = "ready", UpdatedAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
private static IResult RequestPromotion(string id, [FromBody] PromoteDto request)
|
||||
private static IResult RequestPromotion(
|
||||
string id,
|
||||
[FromBody] PromoteDto request,
|
||||
[FromServices] TimeProvider time)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with { TargetEnvironment = request.TargetEnvironment, UpdatedAt = DateTimeOffset.UtcNow });
|
||||
|
||||
var targetEnvironment = ResolveTargetEnvironment(request);
|
||||
var existing = ApprovalEndpoints.SeedData.Approvals
|
||||
.Select(ApprovalEndpoints.WithDerivedSignals)
|
||||
.FirstOrDefault(a =>
|
||||
string.Equals(a.ReleaseId, id, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(a.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(a.Status, "pending", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Ok(ApprovalEndpoints.ToSummary(existing));
|
||||
}
|
||||
|
||||
var nextId = $"apr-{ApprovalEndpoints.SeedData.Approvals.Count + 1:000}";
|
||||
var now = time.GetUtcNow().ToString("O");
|
||||
var approval = ApprovalEndpoints.WithDerivedSignals(new ApprovalEndpoints.ApprovalDto
|
||||
{
|
||||
Id = nextId,
|
||||
ReleaseId = release.Id,
|
||||
ReleaseName = release.Name,
|
||||
ReleaseVersion = release.Version,
|
||||
SourceEnvironment = release.CurrentEnvironment ?? "staging",
|
||||
TargetEnvironment = targetEnvironment,
|
||||
RequestedBy = "release-orchestrator",
|
||||
RequestedAt = now,
|
||||
Urgency = request.Urgency ?? "normal",
|
||||
Justification = string.IsNullOrWhiteSpace(request.Justification)
|
||||
? $"Promotion requested for {release.Name} {release.Version}."
|
||||
: request.Justification.Trim(),
|
||||
Status = "pending",
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
GatesPassed = true,
|
||||
ScheduledTime = request.ScheduledTime,
|
||||
ExpiresAt = time.GetUtcNow().AddHours(48).ToString("O"),
|
||||
GateResults = new List<ApprovalEndpoints.GateResultDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GateId = "g-security",
|
||||
GateName = "Security Snapshot",
|
||||
Type = "security",
|
||||
Status = "passed",
|
||||
Message = "Critical reachable findings within policy threshold.",
|
||||
Details = new Dictionary<string, object>(),
|
||||
EvaluatedAt = now,
|
||||
},
|
||||
new()
|
||||
{
|
||||
GateId = "g-ops",
|
||||
GateName = "Data Integrity",
|
||||
Type = "quality",
|
||||
Status = "warning",
|
||||
Message = "Runtime ingest lag reduces confidence for production decisions.",
|
||||
Details = new Dictionary<string, object>(),
|
||||
EvaluatedAt = now,
|
||||
},
|
||||
},
|
||||
ReleaseComponents = BuildReleaseComponents(release.Id),
|
||||
});
|
||||
|
||||
ApprovalEndpoints.SeedData.Approvals.Add(approval);
|
||||
return Results.Ok(ApprovalEndpoints.ToSummary(approval));
|
||||
}
|
||||
|
||||
private static IResult Deploy(string id)
|
||||
@@ -307,21 +432,34 @@ public static class ReleaseEndpoints
|
||||
|
||||
private static IResult GetPromotionPreview(string releaseId, [FromQuery] string? targetEnvironmentId)
|
||||
{
|
||||
var targetEnvironment = targetEnvironmentId == "env-production" ? "production" : "staging";
|
||||
var risk = ReleaseControlSignalCatalog.GetRiskSnapshot(releaseId, targetEnvironment);
|
||||
var coverage = ReleaseControlSignalCatalog.GetCoverage(releaseId);
|
||||
var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment);
|
||||
var manifestDigest = ResolveManifestDigest(releaseId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
releaseId,
|
||||
releaseName = "Platform Release",
|
||||
sourceEnvironment = "staging",
|
||||
targetEnvironment = targetEnvironmentId == "env-production" ? "production" : "staging",
|
||||
targetEnvironment,
|
||||
manifestDigest,
|
||||
riskSnapshot = risk,
|
||||
reachabilityCoverage = coverage,
|
||||
opsConfidence = ops,
|
||||
gateResults = new[]
|
||||
{
|
||||
new { gateId = "g1", gateName = "Security Scan", type = "security", status = "passed", message = "No vulnerabilities found", details = new Dictionary<string, object>(), evaluatedAt = DateTimeOffset.UtcNow },
|
||||
new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary<string, object>(), evaluatedAt = DateTimeOffset.UtcNow },
|
||||
new { gateId = "g1", gateName = "Security Scan", type = "security", status = "passed", message = "No blocking vulnerabilities found", details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g3", gateName = "Ops Data Integrity", type = "quality", status = ops.Status == "healthy" ? "passed" : "warning", message = ops.Summary, details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
},
|
||||
allGatesPassed = true,
|
||||
requiredApprovers = 2,
|
||||
estimatedDeployTime = 300,
|
||||
warnings = Array.Empty<string>(),
|
||||
warnings = ops.Status == "healthy"
|
||||
? Array.Empty<string>()
|
||||
: new[] { "Data-integrity confidence is degraded; decision remains auditable but requires explicit acknowledgment." },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,12 +467,56 @@ public static class ReleaseEndpoints
|
||||
{
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
new { id = "env-staging", name = "Staging", tier = "staging" },
|
||||
new { id = "env-production", name = "Production", tier = "production" },
|
||||
new { id = "env-canary", name = "Canary", tier = "production" },
|
||||
new { id = "env-staging", name = "Staging", tier = "staging", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("staging") },
|
||||
new { id = "env-production", name = "Production", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("production") },
|
||||
new { id = "env-canary", name = "Canary", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("canary") },
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveTargetEnvironment(PromoteDto request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.TargetEnvironment))
|
||||
{
|
||||
return request.TargetEnvironment.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return request.TargetEnvironmentId switch
|
||||
{
|
||||
"env-production" => "production",
|
||||
"env-canary" => "canary",
|
||||
_ => "staging",
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveManifestDigest(string releaseId)
|
||||
{
|
||||
if (SeedData.Components.TryGetValue(releaseId, out var components) && components.Count > 0)
|
||||
{
|
||||
var digestSeed = string.Join('|', components.Select(component => component.Digest));
|
||||
return $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestSeed))).ToLowerInvariant()[..64]}";
|
||||
}
|
||||
|
||||
return $"sha256:{releaseId.Replace("-", string.Empty, StringComparison.Ordinal).PadRight(64, '0')[..64]}";
|
||||
}
|
||||
|
||||
private static List<ApprovalEndpoints.ReleaseComponentSummaryDto> BuildReleaseComponents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
{
|
||||
return new List<ApprovalEndpoints.ReleaseComponentSummaryDto>();
|
||||
}
|
||||
|
||||
return components
|
||||
.OrderBy(component => component.Name, StringComparer.Ordinal)
|
||||
.Select(component => new ApprovalEndpoints.ReleaseComponentSummaryDto
|
||||
{
|
||||
Name = component.Name,
|
||||
Version = component.Version,
|
||||
Digest = component.Digest,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ManagedReleaseDto
|
||||
|
||||
@@ -156,6 +156,7 @@ app.MapDeadLetterEndpoints();
|
||||
app.MapReleaseEndpoints();
|
||||
app.MapApprovalEndpoints();
|
||||
app.MapReleaseDashboardEndpoints();
|
||||
app.MapReleaseControlV2Endpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using StellaOps.Orchestrator.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic signal projections used by release-control contract adapters.
|
||||
/// </summary>
|
||||
public static class ReleaseControlSignalCatalog
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, PromotionRiskSnapshot> RiskByRelease =
|
||||
new Dictionary<string, PromotionRiskSnapshot>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["rel-001"] = new("production", 0, 0, 1, 96.5m, "clean"),
|
||||
["rel-002"] = new("production", 1, 1, 3, 62.0m, "warning"),
|
||||
["rel-003"] = new("production", 2, 1, 2, 58.0m, "blocked"),
|
||||
["rel-004"] = new("dev", 0, 1, 1, 88.0m, "warning"),
|
||||
["rel-005"] = new("production", 0, 0, 0, 97.0m, "clean"),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, HybridReachabilityCoverage> CoverageByRelease =
|
||||
new Dictionary<string, HybridReachabilityCoverage>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["rel-001"] = new(100, 100, 92, 2),
|
||||
["rel-002"] = new(100, 86, 41, 26),
|
||||
["rel-003"] = new(100, 80, 35, 31),
|
||||
["rel-004"] = new(100, 72, 0, 48),
|
||||
["rel-005"] = new(100, 100, 100, 1),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, OpsDataConfidence> OpsByEnvironment =
|
||||
new Dictionary<string, OpsDataConfidence>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = new(
|
||||
"warning",
|
||||
"NVD freshness and runtime ingest lag reduce decision confidence.",
|
||||
71,
|
||||
DateTimeOffset.Parse("2026-02-19T03:15:00Z"),
|
||||
new[]
|
||||
{
|
||||
"feeds:nvd=warn(3h stale)",
|
||||
"sbom-rescan=fail(12 digests stale)",
|
||||
"reach-runtime=warn(agent degraded)",
|
||||
}),
|
||||
["staging"] = new(
|
||||
"healthy",
|
||||
"All freshness and ingest checks are within policy threshold.",
|
||||
94,
|
||||
DateTimeOffset.Parse("2026-02-19T03:15:00Z"),
|
||||
new[]
|
||||
{
|
||||
"feeds=ok",
|
||||
"sbom-rescan=ok",
|
||||
"reach-runtime=ok",
|
||||
}),
|
||||
["dev"] = new(
|
||||
"warning",
|
||||
"Runtime evidence coverage is limited for non-prod workloads.",
|
||||
78,
|
||||
DateTimeOffset.Parse("2026-02-19T03:15:00Z"),
|
||||
new[]
|
||||
{
|
||||
"feeds=ok",
|
||||
"sbom-rescan=ok",
|
||||
"reach-runtime=warn(low coverage)",
|
||||
}),
|
||||
["canary"] = new(
|
||||
"healthy",
|
||||
"Canary telemetry and feed freshness are green.",
|
||||
90,
|
||||
DateTimeOffset.Parse("2026-02-19T03:15:00Z"),
|
||||
new[]
|
||||
{
|
||||
"feeds=ok",
|
||||
"sbom-rescan=ok",
|
||||
"reach-runtime=ok",
|
||||
}),
|
||||
};
|
||||
|
||||
public static PromotionRiskSnapshot GetRiskSnapshot(string releaseId, string targetEnvironment)
|
||||
{
|
||||
if (RiskByRelease.TryGetValue(releaseId, out var risk))
|
||||
{
|
||||
return string.Equals(risk.EnvironmentId, targetEnvironment, StringComparison.OrdinalIgnoreCase)
|
||||
? risk
|
||||
: risk with { EnvironmentId = targetEnvironment };
|
||||
}
|
||||
|
||||
return new PromotionRiskSnapshot(targetEnvironment, 0, 0, 0, 100m, "clean");
|
||||
}
|
||||
|
||||
public static HybridReachabilityCoverage GetCoverage(string releaseId)
|
||||
{
|
||||
return CoverageByRelease.TryGetValue(releaseId, out var coverage)
|
||||
? coverage
|
||||
: new HybridReachabilityCoverage(100, 100, 100, 1);
|
||||
}
|
||||
|
||||
public static OpsDataConfidence GetOpsConfidence(string targetEnvironment)
|
||||
{
|
||||
return OpsByEnvironment.TryGetValue(targetEnvironment, out var confidence)
|
||||
? confidence
|
||||
: new OpsDataConfidence(
|
||||
"unknown",
|
||||
"No platform data-integrity signal is available for this environment.",
|
||||
0,
|
||||
DateTimeOffset.Parse("2026-02-19T03:15:00Z"),
|
||||
new[] { "platform-signal=missing" });
|
||||
}
|
||||
|
||||
public static ApprovalEvidencePacket BuildEvidencePacket(string approvalId, string releaseId)
|
||||
{
|
||||
var suffix = $"{releaseId}-{approvalId}".Replace(":", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
return new ApprovalEvidencePacket(
|
||||
DecisionDigest: $"sha256:decision-{suffix}",
|
||||
PolicyDecisionDsse: $"policy-decision-{approvalId}.dsse",
|
||||
SbomSnapshotId: $"sbom-snapshot-{releaseId}",
|
||||
ReachabilitySnapshotId: $"reachability-snapshot-{releaseId}",
|
||||
DataIntegritySnapshotId: $"ops-snapshot-{releaseId}");
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,17 @@ public static class PlatformPolicies
|
||||
public const string PolicyRead = "platform.policy.read";
|
||||
public const string PolicyWrite = "platform.policy.write";
|
||||
public const string PolicyEvaluate = "platform.policy.evaluate";
|
||||
|
||||
// Release control bundle lifecycle policies (SPRINT_20260219_008 / BE8-02)
|
||||
public const string ReleaseControlRead = "platform.releasecontrol.read";
|
||||
public const string ReleaseControlOperate = "platform.releasecontrol.operate";
|
||||
|
||||
// Federated telemetry policies (SPRINT_20260220_007)
|
||||
public const string FederationRead = "platform.federation.read";
|
||||
public const string FederationManage = "platform.federation.manage";
|
||||
|
||||
// Trust ownership transition policies (Pack-21 follow-on auth hardening)
|
||||
public const string TrustRead = "platform.trust.read";
|
||||
public const string TrustWrite = "platform.trust.write";
|
||||
public const string TrustAdmin = "platform.trust.admin";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Constants;
|
||||
|
||||
public static class PlatformScopes
|
||||
@@ -30,4 +32,17 @@ public static class PlatformScopes
|
||||
public const string PolicyRead = "policy.read";
|
||||
public const string PolicyWrite = "policy.write";
|
||||
public const string PolicyEvaluate = "policy.evaluate";
|
||||
|
||||
// Release control bundle lifecycle scopes (SPRINT_20260219_008 / BE8-02)
|
||||
public const string OrchRead = "orch:read";
|
||||
public const string OrchOperate = "orch:operate";
|
||||
|
||||
// Federated telemetry scopes (SPRINT_20260220_007)
|
||||
public const string FederationRead = "platform:federation:read";
|
||||
public const string FederationManage = "platform:federation:manage";
|
||||
|
||||
// Trust ownership transition scopes (Pack-21 follow-on auth hardening)
|
||||
public const string TrustRead = StellaOpsScopes.TrustRead;
|
||||
public const string TrustWrite = StellaOpsScopes.TrustWrite;
|
||||
public const string TrustAdmin = StellaOpsScopes.TrustAdmin;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record AdministrationTrustKeySummary(
|
||||
Guid KeyId,
|
||||
string Alias,
|
||||
string Algorithm,
|
||||
string Status,
|
||||
int CurrentVersion,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTrustIssuerSummary(
|
||||
Guid IssuerId,
|
||||
string Name,
|
||||
string IssuerUri,
|
||||
string TrustLevel,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTrustCertificateSummary(
|
||||
Guid CertificateId,
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
string SerialNumber,
|
||||
string Status,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTransparencyLogConfig(
|
||||
string LogUrl,
|
||||
string? WitnessUrl,
|
||||
bool EnforceInclusion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record CreateAdministrationTrustKeyRequest(
|
||||
string Alias,
|
||||
string Algorithm,
|
||||
string? MetadataJson);
|
||||
|
||||
public sealed record RotateAdministrationTrustKeyRequest(
|
||||
string? Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RevokeAdministrationTrustKeyRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RegisterAdministrationTrustIssuerRequest(
|
||||
string Name,
|
||||
string IssuerUri,
|
||||
string TrustLevel);
|
||||
|
||||
public sealed record RegisterAdministrationTrustCertificateRequest(
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
string SerialNumber,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter);
|
||||
|
||||
public sealed record RevokeAdministrationTrustCertificateRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record ConfigureAdministrationTransparencyLogRequest(
|
||||
string LogUrl,
|
||||
string? WitnessUrl,
|
||||
bool EnforceInclusion);
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record FederationConsentStateResponse(
|
||||
bool Granted,
|
||||
string? GrantedBy,
|
||||
DateTimeOffset? GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? DsseDigest);
|
||||
|
||||
public sealed record FederationGrantConsentRequest(
|
||||
string GrantedBy,
|
||||
int? TtlHours);
|
||||
|
||||
public sealed record FederationConsentProofResponse(
|
||||
string TenantId,
|
||||
string GrantedBy,
|
||||
DateTimeOffset GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string DsseDigest);
|
||||
|
||||
public sealed record FederationRevokeConsentRequest(
|
||||
string RevokedBy);
|
||||
|
||||
public sealed record FederationStatusResponse(
|
||||
bool Enabled,
|
||||
bool SealedMode,
|
||||
string SiteId,
|
||||
bool ConsentGranted,
|
||||
double EpsilonRemaining,
|
||||
double EpsilonTotal,
|
||||
bool BudgetExhausted,
|
||||
DateTimeOffset NextBudgetReset,
|
||||
int BundleCount);
|
||||
|
||||
public sealed record FederationBundleSummary(
|
||||
Guid Id,
|
||||
string SourceSiteId,
|
||||
int BucketCount,
|
||||
int SuppressedBuckets,
|
||||
double EpsilonSpent,
|
||||
bool Verified,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record FederationBundleDetailResponse(
|
||||
Guid Id,
|
||||
string SourceSiteId,
|
||||
int TotalFacts,
|
||||
int BucketCount,
|
||||
int SuppressedBuckets,
|
||||
double EpsilonSpent,
|
||||
string ConsentDsseDigest,
|
||||
string BundleDsseDigest,
|
||||
bool Verified,
|
||||
DateTimeOffset AggregatedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<FederationBucketDetail> Buckets);
|
||||
|
||||
public sealed record FederationBucketDetail(
|
||||
string CveId,
|
||||
int ObservationCount,
|
||||
int ArtifactCount,
|
||||
double NoisyCount,
|
||||
bool Suppressed);
|
||||
|
||||
public sealed record FederationIntelligenceResponse(
|
||||
IReadOnlyList<FederationIntelligenceEntry> Entries,
|
||||
int TotalEntries,
|
||||
int UniqueCves,
|
||||
int ContributingSites,
|
||||
DateTimeOffset LastUpdated);
|
||||
|
||||
public sealed record FederationIntelligenceEntry(
|
||||
string CveId,
|
||||
string SourceSiteId,
|
||||
int ObservationCount,
|
||||
double NoisyCount,
|
||||
int ArtifactCount,
|
||||
DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record FederationPrivacyBudgetResponse(
|
||||
double Remaining,
|
||||
double Total,
|
||||
bool Exhausted,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset NextReset,
|
||||
int QueriesThisPeriod,
|
||||
int SuppressedThisPeriod);
|
||||
|
||||
public sealed record FederationTriggerResponse(
|
||||
bool Triggered,
|
||||
string? Reason);
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record ReleaseControlBundleSummary(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description,
|
||||
int TotalVersions,
|
||||
int? LatestVersionNumber,
|
||||
Guid? LatestVersionId,
|
||||
string? LatestVersionDigest,
|
||||
DateTimeOffset? LatestPublishedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record ReleaseControlBundleDetail(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description,
|
||||
int TotalVersions,
|
||||
int? LatestVersionNumber,
|
||||
Guid? LatestVersionId,
|
||||
string? LatestVersionDigest,
|
||||
DateTimeOffset? LatestPublishedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CreatedBy);
|
||||
|
||||
public sealed record ReleaseControlBundleVersionSummary(
|
||||
Guid Id,
|
||||
Guid BundleId,
|
||||
int VersionNumber,
|
||||
string Digest,
|
||||
string Status,
|
||||
int ComponentsCount,
|
||||
string? Changelog,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string CreatedBy);
|
||||
|
||||
public sealed record ReleaseControlBundleComponent(
|
||||
string ComponentVersionId,
|
||||
string ComponentName,
|
||||
string ImageDigest,
|
||||
int DeployOrder,
|
||||
string MetadataJson);
|
||||
|
||||
public sealed record ReleaseControlBundleVersionDetail(
|
||||
Guid Id,
|
||||
Guid BundleId,
|
||||
int VersionNumber,
|
||||
string Digest,
|
||||
string Status,
|
||||
int ComponentsCount,
|
||||
string? Changelog,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string CreatedBy,
|
||||
IReadOnlyList<ReleaseControlBundleComponent> Components);
|
||||
|
||||
public sealed record ReleaseControlBundleMaterializationRun(
|
||||
Guid RunId,
|
||||
Guid BundleId,
|
||||
Guid VersionId,
|
||||
string Status,
|
||||
string? TargetEnvironment,
|
||||
string? Reason,
|
||||
string RequestedBy,
|
||||
string? IdempotencyKey,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record CreateReleaseControlBundleRequest(
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description);
|
||||
|
||||
public sealed record PublishReleaseControlBundleVersionRequest(
|
||||
string? Changelog,
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput>? Components);
|
||||
|
||||
public sealed record ReleaseControlBundleComponentInput(
|
||||
string ComponentVersionId,
|
||||
string ComponentName,
|
||||
string ImageDigest,
|
||||
int DeployOrder,
|
||||
string? MetadataJson);
|
||||
|
||||
public sealed record MaterializeReleaseControlBundleVersionRequest(
|
||||
string? TargetEnvironment,
|
||||
string? Reason,
|
||||
string? IdempotencyKey);
|
||||
@@ -0,0 +1,452 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Trust and signing owner mutation endpoints backing Administration A6.
|
||||
/// </summary>
|
||||
public static class AdministrationTrustSigningMutationEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapAdministrationTrustSigningMutationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/administration/trust-signing")
|
||||
.WithTags("Administration");
|
||||
|
||||
group.MapGet("/keys", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListKeysAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustKeySummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustKeys")
|
||||
.WithSummary("List trust signing keys")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/keys", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.CreateKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/keys/{created.KeyId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("CreateAdministrationTrustKey")
|
||||
.WithSummary("Create trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/keys/{keyId:guid}/rotate", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RotateKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
keyId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RotateAdministrationTrustKey")
|
||||
.WithSummary("Rotate trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/keys/{keyId:guid}/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RevokeKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
keyId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RevokeAdministrationTrustKey")
|
||||
.WithSummary("Revoke trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/issuers", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListIssuersAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustIssuerSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustIssuers")
|
||||
.WithSummary("List trust issuers")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/issuers", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.RegisterIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/issuers/{created.IssuerId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RegisterAdministrationTrustIssuer")
|
||||
.WithSummary("Register trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapGet("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListCertificatesAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustCertificateSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustCertificates")
|
||||
.WithSummary("List trust certificates")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.RegisterCertificateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/certificates/{created.CertificateId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RegisterAdministrationTrustCertificate")
|
||||
.WithSummary("Register trust certificate")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/certificates/{certificateId:guid}/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RevokeCertificateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
certificateId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId);
|
||||
}
|
||||
})
|
||||
.WithName("RevokeAdministrationTrustCertificate")
|
||||
.WithSummary("Revoke trust certificate")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/transparency-log", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var config = await store.GetTransparencyLogConfigAsync(
|
||||
requestContext!.TenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "transparency_log_not_configured" });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<AdministrationTransparencyLogConfig>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
config));
|
||||
})
|
||||
.WithName("GetAdministrationTrustTransparencyLog")
|
||||
.WithSummary("Get trust transparency log configuration")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPut("/transparency-log", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await store.ConfigureTransparencyLogAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(config);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("ConfigureAdministrationTrustTransparencyLog")
|
||||
.WithSummary("Configure trust transparency log")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult MapStoreError(InvalidOperationException exception, Guid? keyId, Guid? certificateId)
|
||||
{
|
||||
return exception.Message switch
|
||||
{
|
||||
"request_required" => Results.BadRequest(new { error = "request_required" }),
|
||||
"tenant_required" => Results.BadRequest(new { error = "tenant_required" }),
|
||||
"tenant_id_invalid" => Results.BadRequest(new { error = "tenant_id_invalid" }),
|
||||
"reason_required" => Results.BadRequest(new { error = "reason_required" }),
|
||||
"key_alias_required" => Results.BadRequest(new { error = "key_alias_required" }),
|
||||
"key_algorithm_required" => Results.BadRequest(new { error = "key_algorithm_required" }),
|
||||
"key_alias_exists" => Results.Conflict(new { error = "key_alias_exists" }),
|
||||
"key_not_found" => Results.NotFound(new { error = "key_not_found", keyId }),
|
||||
"key_revoked" => Results.Conflict(new { error = "key_revoked", keyId }),
|
||||
"issuer_name_required" => Results.BadRequest(new { error = "issuer_name_required" }),
|
||||
"issuer_uri_required" => Results.BadRequest(new { error = "issuer_uri_required" }),
|
||||
"issuer_uri_invalid" => Results.BadRequest(new { error = "issuer_uri_invalid" }),
|
||||
"issuer_trust_level_required" => Results.BadRequest(new { error = "issuer_trust_level_required" }),
|
||||
"issuer_uri_exists" => Results.Conflict(new { error = "issuer_uri_exists" }),
|
||||
"issuer_not_found" => Results.NotFound(new { error = "issuer_not_found" }),
|
||||
"certificate_serial_required" => Results.BadRequest(new { error = "certificate_serial_required" }),
|
||||
"certificate_validity_invalid" => Results.BadRequest(new { error = "certificate_validity_invalid" }),
|
||||
"certificate_serial_exists" => Results.Conflict(new { error = "certificate_serial_exists" }),
|
||||
"certificate_not_found" => Results.NotFound(new { error = "certificate_not_found", certificateId }),
|
||||
"transparency_log_url_required" => Results.BadRequest(new { error = "transparency_log_url_required" }),
|
||||
"transparency_log_url_invalid" => Results.BadRequest(new { error = "transparency_log_url_invalid" }),
|
||||
"transparency_witness_url_invalid" => Results.BadRequest(new { error = "transparency_witness_url_invalid" }),
|
||||
_ => Results.BadRequest(new { error = exception.Message })
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Intelligence;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class FederationTelemetryEndpoints
|
||||
{
|
||||
// In-memory bundle store for MVP; production would use persistent store
|
||||
private static readonly List<FederatedBundle> _bundles = new();
|
||||
private static readonly object _bundleLock = new();
|
||||
|
||||
public static IEndpointRouteBuilder MapFederationTelemetryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/telemetry/federation")
|
||||
.WithTags("Federated Telemetry");
|
||||
|
||||
// GET /consent — get consent state
|
||||
group.MapGet("/consent", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var state = await consentManager.GetConsentStateAsync(requestContext!.TenantId, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationConsentStateResponse(
|
||||
state.Granted, state.GrantedBy, state.GrantedAt, state.ExpiresAt, state.DsseDigest));
|
||||
})
|
||||
.WithName("GetFederationConsent")
|
||||
.WithSummary("Get federation consent state for current tenant")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// POST /consent/grant — grant consent
|
||||
group.MapPost("/consent/grant", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
FederationGrantConsentRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
TimeSpan? ttl = request.TtlHours.HasValue
|
||||
? TimeSpan.FromHours(request.TtlHours.Value)
|
||||
: null;
|
||||
|
||||
var proof = await consentManager.GrantConsentAsync(
|
||||
requestContext!.TenantId, request.GrantedBy, ttl, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationConsentProofResponse(
|
||||
proof.TenantId, proof.GrantedBy, proof.GrantedAt, proof.ExpiresAt, proof.DsseDigest));
|
||||
})
|
||||
.WithName("GrantFederationConsent")
|
||||
.WithSummary("Grant federation telemetry consent")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
// POST /consent/revoke — revoke consent
|
||||
group.MapPost("/consent/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
FederationRevokeConsentRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
await consentManager.RevokeConsentAsync(requestContext!.TenantId, request.RevokedBy, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { revoked = true });
|
||||
})
|
||||
.WithName("RevokeFederationConsent")
|
||||
.WithSummary("Revoke federation telemetry consent")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
// GET /status — federation status
|
||||
group.MapGet("/status", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
IPrivacyBudgetTracker budgetTracker,
|
||||
Microsoft.Extensions.Options.IOptions<Telemetry.Federation.FederatedTelemetryOptions> fedOptions,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var consent = await consentManager.GetConsentStateAsync(requestContext!.TenantId, ct).ConfigureAwait(false);
|
||||
var snapshot = budgetTracker.GetSnapshot();
|
||||
|
||||
int bundleCount;
|
||||
lock (_bundleLock) { bundleCount = _bundles.Count; }
|
||||
|
||||
return Results.Ok(new FederationStatusResponse(
|
||||
Enabled: !fedOptions.Value.SealedModeEnabled,
|
||||
SealedMode: fedOptions.Value.SealedModeEnabled,
|
||||
SiteId: fedOptions.Value.SiteId,
|
||||
ConsentGranted: consent.Granted,
|
||||
EpsilonRemaining: snapshot.Remaining,
|
||||
EpsilonTotal: snapshot.Total,
|
||||
BudgetExhausted: snapshot.Exhausted,
|
||||
NextBudgetReset: snapshot.NextReset,
|
||||
BundleCount: bundleCount));
|
||||
})
|
||||
.WithName("GetFederationStatus")
|
||||
.WithSummary("Get federation telemetry status")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /bundles — list bundles
|
||||
group.MapGet("/bundles", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
List<FederationBundleSummary> summaries;
|
||||
lock (_bundleLock)
|
||||
{
|
||||
summaries = _bundles.Select(b => new FederationBundleSummary(
|
||||
b.Id, b.SourceSiteId,
|
||||
b.Aggregation.Buckets.Count,
|
||||
b.Aggregation.SuppressedBuckets,
|
||||
b.Aggregation.EpsilonSpent,
|
||||
Verified: true,
|
||||
b.CreatedAt)).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult(Results.Ok(summaries));
|
||||
})
|
||||
.WithName("ListFederationBundles")
|
||||
.WithSummary("List federation telemetry bundles")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /bundles/{id} — bundle detail
|
||||
group.MapGet("/bundles/{id:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFederatedTelemetryBundleBuilder bundleBuilder,
|
||||
Guid id,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
FederatedBundle? bundle;
|
||||
lock (_bundleLock) { bundle = _bundles.FirstOrDefault(b => b.Id == id); }
|
||||
|
||||
if (bundle is null)
|
||||
return Results.NotFound(new { error = "bundle_not_found", id });
|
||||
|
||||
var verified = await bundleBuilder.VerifyAsync(bundle, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationBundleDetailResponse(
|
||||
bundle.Id, bundle.SourceSiteId,
|
||||
bundle.Aggregation.TotalFacts,
|
||||
bundle.Aggregation.Buckets.Count,
|
||||
bundle.Aggregation.SuppressedBuckets,
|
||||
bundle.Aggregation.EpsilonSpent,
|
||||
bundle.ConsentDsseDigest,
|
||||
bundle.BundleDsseDigest,
|
||||
verified,
|
||||
bundle.Aggregation.AggregatedAt,
|
||||
bundle.CreatedAt,
|
||||
bundle.Aggregation.Buckets.Select(b => new FederationBucketDetail(
|
||||
b.CveId, b.ObservationCount, b.ArtifactCount, b.NoisyCount, b.Suppressed)).ToList()));
|
||||
})
|
||||
.WithName("GetFederationBundle")
|
||||
.WithSummary("Get federation telemetry bundle detail")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /intelligence — exploit corpus
|
||||
group.MapGet("/intelligence", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IExploitIntelligenceMerger intelligenceMerger,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
var corpus = await intelligenceMerger.GetCorpusAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationIntelligenceResponse(
|
||||
corpus.Entries.Select(e => new FederationIntelligenceEntry(
|
||||
e.CveId, e.SourceSiteId, e.ObservationCount, e.NoisyCount, e.ArtifactCount, e.ObservedAt)).ToList(),
|
||||
corpus.TotalEntries,
|
||||
corpus.UniqueCves,
|
||||
corpus.ContributingSites,
|
||||
corpus.LastUpdated));
|
||||
})
|
||||
.WithName("GetFederationIntelligence")
|
||||
.WithSummary("Get shared exploit intelligence corpus")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /privacy-budget — budget snapshot
|
||||
group.MapGet("/privacy-budget", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPrivacyBudgetTracker budgetTracker) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
var snapshot = budgetTracker.GetSnapshot();
|
||||
|
||||
return Task.FromResult(Results.Ok(new FederationPrivacyBudgetResponse(
|
||||
snapshot.Remaining, snapshot.Total, snapshot.Exhausted,
|
||||
snapshot.PeriodStart, snapshot.NextReset,
|
||||
snapshot.QueriesThisPeriod, snapshot.SuppressedThisPeriod)));
|
||||
})
|
||||
.WithName("GetFederationPrivacyBudget")
|
||||
.WithSummary("Get privacy budget snapshot")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// POST /trigger — trigger aggregation
|
||||
group.MapPost("/trigger", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPrivacyBudgetTracker budgetTracker) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
if (budgetTracker.IsBudgetExhausted)
|
||||
{
|
||||
return Task.FromResult(Results.Ok(new FederationTriggerResponse(
|
||||
Triggered: false,
|
||||
Reason: "Privacy budget exhausted")));
|
||||
}
|
||||
|
||||
// Placeholder: actual implementation would trigger sync service
|
||||
return Task.FromResult(Results.Ok(new FederationTriggerResponse(
|
||||
Triggered: true,
|
||||
Reason: null)));
|
||||
})
|
||||
.WithName("TriggerFederationAggregation")
|
||||
.WithSummary("Trigger manual federation aggregation cycle")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Pack-driven adapter endpoints for Dashboard, Platform Ops, and Administration views.
|
||||
/// </summary>
|
||||
public static class PackAdapterEndpoints
|
||||
{
|
||||
private static readonly DateTimeOffset SnapshotAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
|
||||
|
||||
public static IEndpointRouteBuilder MapPackAdapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/dashboard/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDashboardSummary();
|
||||
return Results.Ok(new PlatformItemResponse<DashboardSummaryDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithTags("Dashboard")
|
||||
.WithName("GetDashboardSummary")
|
||||
.WithSummary("Pack v2 dashboard summary projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
var platform = app.MapGroup("/api/v1/platform")
|
||||
.WithTags("Platform Ops");
|
||||
|
||||
platform.MapGet("/data-integrity/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDataIntegritySummary();
|
||||
return Results.Ok(new PlatformItemResponse<DataIntegritySummaryDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetDataIntegritySummary")
|
||||
.WithSummary("Pack v2 data-integrity card summary.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/data-integrity/report", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDataIntegrityReport();
|
||||
return Results.Ok(new PlatformItemResponse<DataIntegrityReportDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetDataIntegrityReport")
|
||||
.WithSummary("Pack v2 nightly data-integrity report projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/feeds/freshness", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var feeds = BuildFeedFreshness();
|
||||
return Results.Ok(new PlatformListResponse<FeedFreshnessDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
feeds,
|
||||
feeds.Count));
|
||||
})
|
||||
.WithName("GetFeedsFreshness")
|
||||
.WithSummary("Pack v2 advisory/feed freshness projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/scan-pipeline/health", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildScanPipelineHealth();
|
||||
return Results.Ok(new PlatformItemResponse<ScanPipelineHealthDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetScanPipelineHealth")
|
||||
.WithSummary("Pack v2 scan-pipeline health projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/reachability/ingest-health", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildReachabilityIngestHealth();
|
||||
return Results.Ok(new PlatformItemResponse<ReachabilityIngestHealthDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetReachabilityIngestHealth")
|
||||
.WithSummary("Pack v2 reachability ingest health projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
var administration = app.MapGroup("/api/v1/administration")
|
||||
.WithTags("Administration");
|
||||
|
||||
administration.MapGet("/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationSummary);
|
||||
})
|
||||
.WithName("GetAdministrationSummary")
|
||||
.WithSummary("Pack v2 administration overview cards.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/identity-access", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationIdentityAccess);
|
||||
})
|
||||
.WithName("GetAdministrationIdentityAccess")
|
||||
.WithSummary("Pack v2 administration A1 identity and access projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/tenant-branding", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationTenantBranding);
|
||||
})
|
||||
.WithName("GetAdministrationTenantBranding")
|
||||
.WithSummary("Pack v2 administration A2 tenant and branding projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/notifications", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationNotifications);
|
||||
})
|
||||
.WithName("GetAdministrationNotifications")
|
||||
.WithSummary("Pack v2 administration A3 notifications projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/usage-limits", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationUsageLimits);
|
||||
})
|
||||
.WithName("GetAdministrationUsageLimits")
|
||||
.WithSummary("Pack v2 administration A4 usage and limits projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/policy-governance", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationPolicyGovernance);
|
||||
})
|
||||
.WithName("GetAdministrationPolicyGovernance")
|
||||
.WithSummary("Pack v2 administration A5 policy governance projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/trust-signing", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationTrustSigning);
|
||||
})
|
||||
.WithName("GetAdministrationTrustSigning")
|
||||
.WithSummary("Pack v2 administration A6 trust and signing projection.")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
administration.MapGet("/system", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationSystem);
|
||||
})
|
||||
.WithName("GetAdministrationSystem")
|
||||
.WithSummary("Pack v2 administration A7 system projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static DashboardSummaryDto BuildDashboardSummary()
|
||||
{
|
||||
var confidence = BuildConfidenceBadge();
|
||||
var environments = new[]
|
||||
{
|
||||
new EnvironmentRiskSnapshotDto("apac-prod", CriticalReachable: 0, HighReachable: 0, SbomState: "fresh"),
|
||||
new EnvironmentRiskSnapshotDto("eu-prod", CriticalReachable: 0, HighReachable: 1, SbomState: "fresh"),
|
||||
new EnvironmentRiskSnapshotDto("us-prod", CriticalReachable: 2, HighReachable: 1, SbomState: "stale"),
|
||||
new EnvironmentRiskSnapshotDto("us-uat", CriticalReachable: 1, HighReachable: 2, SbomState: "stale"),
|
||||
}.OrderBy(item => item.Environment, StringComparer.Ordinal).ToList();
|
||||
|
||||
var topDrivers = new[]
|
||||
{
|
||||
new DashboardDriverDto("CVE-2026-1234", "user-service", "critical", "reachable"),
|
||||
new DashboardDriverDto("CVE-2026-2222", "billing-worker", "critical", "reachable"),
|
||||
new DashboardDriverDto("CVE-2026-9001", "api-gateway", "high", "not_reachable"),
|
||||
};
|
||||
|
||||
return new DashboardSummaryDto(
|
||||
DataConfidence: confidence,
|
||||
EnvironmentsWithCriticalReachable: 2,
|
||||
TotalCriticalReachable: 3,
|
||||
SbomCoveragePercent: 98.0m,
|
||||
VexCoveragePercent: 62.0m,
|
||||
BlockedApprovals: 2,
|
||||
ExceptionsExpiringSoon: 4,
|
||||
EnvironmentRisk: environments,
|
||||
TopDrivers: topDrivers);
|
||||
}
|
||||
|
||||
private static DataIntegritySummaryDto BuildDataIntegritySummary()
|
||||
{
|
||||
var cards = new[]
|
||||
{
|
||||
new DataIntegritySignalDto("feeds", "Advisory feeds", "warning", "NVD mirror stale by 3h.", "/api/v1/platform/feeds/freshness"),
|
||||
new DataIntegritySignalDto("reachability", "Reachability ingest", "warning", "Runtime ingest lag exceeds policy threshold.", "/api/v1/platform/reachability/ingest-health"),
|
||||
new DataIntegritySignalDto("scan-pipeline", "Scan pipeline", "warning", "Pending SBOM rescans create stale risk windows.", "/api/v1/platform/scan-pipeline/health"),
|
||||
new DataIntegritySignalDto("sbom", "SBOM coverage", "warning", "12 digests missing a fresh scan snapshot.", "/api/v1/platform/data-integrity/report"),
|
||||
}.OrderBy(card => card.Id, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new DataIntegritySummaryDto(
|
||||
Confidence: BuildConfidenceBadge(),
|
||||
Signals: cards);
|
||||
}
|
||||
|
||||
private static DataIntegrityReportDto BuildDataIntegrityReport()
|
||||
{
|
||||
var sections = new[]
|
||||
{
|
||||
new DataIntegrityReportSectionDto(
|
||||
"advisory-feeds",
|
||||
"warning",
|
||||
"NVD and vendor feed freshness lag detected.",
|
||||
["nvd stale by 3h", "vendor feed retry budget exceeded once"]),
|
||||
new DataIntegrityReportSectionDto(
|
||||
"scan-pipeline",
|
||||
"warning",
|
||||
"Scan backlog increased due to transient worker degradation.",
|
||||
["pending sbom rescans: 12", "oldest pending digest age: 26h"]),
|
||||
new DataIntegrityReportSectionDto(
|
||||
"reachability",
|
||||
"warning",
|
||||
"Runtime attestations delayed in one region.",
|
||||
["us-east runtime agents degraded", "eu-west ingest healthy"]),
|
||||
}.OrderBy(section => section.SectionId, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new DataIntegrityReportDto(
|
||||
ReportId: "ops-nightly-2026-02-19",
|
||||
GeneratedAt: SnapshotAt,
|
||||
Window: "2026-02-18T03:15:00Z/2026-02-19T03:15:00Z",
|
||||
Sections: sections,
|
||||
RecommendedActions:
|
||||
[
|
||||
"Prioritize runtime ingest queue drain in us-east.",
|
||||
"Force-feed refresh for NVD source before next approval window.",
|
||||
"Trigger high-risk SBOM rescan profile for stale production digests.",
|
||||
]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FeedFreshnessDto> BuildFeedFreshness()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new FeedFreshnessDto("NVD", "warning", LastSyncedAt: "2026-02-19T00:12:00Z", FreshnessHours: 3, SlaHours: 1),
|
||||
new FeedFreshnessDto("OSV", "healthy", LastSyncedAt: "2026-02-19T03:02:00Z", FreshnessHours: 0, SlaHours: 1),
|
||||
new FeedFreshnessDto("Vendor advisories", "healthy", LastSyncedAt: "2026-02-19T02:48:00Z", FreshnessHours: 0, SlaHours: 2),
|
||||
}.OrderBy(feed => feed.Source, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static ScanPipelineHealthDto BuildScanPipelineHealth()
|
||||
{
|
||||
var stages = new[]
|
||||
{
|
||||
new PipelineStageHealthDto("ingest", "healthy", QueueDepth: 12, OldestAgeMinutes: 8),
|
||||
new PipelineStageHealthDto("normalize", "healthy", QueueDepth: 3, OldestAgeMinutes: 4),
|
||||
new PipelineStageHealthDto("rescan", "warning", QueueDepth: 12, OldestAgeMinutes: 1570),
|
||||
}.OrderBy(stage => stage.Stage, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new ScanPipelineHealthDto(
|
||||
Status: "warning",
|
||||
PendingDigests: 12,
|
||||
FailedJobs24h: 3,
|
||||
Stages: stages);
|
||||
}
|
||||
|
||||
private static ReachabilityIngestHealthDto BuildReachabilityIngestHealth()
|
||||
{
|
||||
var regions = new[]
|
||||
{
|
||||
new RegionIngestHealthDto("apac", "healthy", Backlog: 7, FreshnessMinutes: 6),
|
||||
new RegionIngestHealthDto("eu-west", "healthy", Backlog: 11, FreshnessMinutes: 7),
|
||||
new RegionIngestHealthDto("us-east", "warning", Backlog: 1230, FreshnessMinutes: 42),
|
||||
}.OrderBy(region => region.Region, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new ReachabilityIngestHealthDto(
|
||||
Status: "warning",
|
||||
RuntimeCoveragePercent: 41,
|
||||
Regions: regions);
|
||||
}
|
||||
|
||||
private static AdministrationSummaryDto BuildAdministrationSummary()
|
||||
{
|
||||
var domains = new[]
|
||||
{
|
||||
new AdministrationDomainCardDto("identity", "Identity & Access", "healthy", "Role assignments and API tokens are within policy.", "/administration/identity-access"),
|
||||
new AdministrationDomainCardDto("notifications", "Notifications", "healthy", "All configured notification providers are operational.", "/administration/notifications"),
|
||||
new AdministrationDomainCardDto("policy", "Policy Governance", "warning", "One policy bundle update is pending review.", "/administration/policy-governance"),
|
||||
new AdministrationDomainCardDto("system", "System", "healthy", "Control plane services report healthy heartbeat.", "/administration/system"),
|
||||
new AdministrationDomainCardDto("tenant", "Tenant & Branding", "healthy", "Tenant branding and domain mappings are current.", "/administration/tenant-branding"),
|
||||
new AdministrationDomainCardDto("trust", "Trust & Signing", "warning", "One certificate expires within 10 days.", "/administration/trust-signing"),
|
||||
new AdministrationDomainCardDto("usage", "Usage & Limits", "warning", "Scanner quota at 65% with upward trend.", "/administration/usage"),
|
||||
}.OrderBy(domain => domain.DomainId, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new AdministrationSummaryDto(
|
||||
Domains: domains,
|
||||
ActiveIncidents:
|
||||
[
|
||||
"trust/certificate-expiry-warning",
|
||||
"usage/scanner-quota-warning",
|
||||
]);
|
||||
}
|
||||
|
||||
private static AdministrationIdentityAccessDto BuildAdministrationIdentityAccess()
|
||||
{
|
||||
var tabs = new[]
|
||||
{
|
||||
new AdministrationFacetTabDto("api-tokens", "API Tokens", Count: 12, Status: "warning", ActionPath: "/administration/identity-access/tokens"),
|
||||
new AdministrationFacetTabDto("oauth-clients", "OAuth/SSO Clients", Count: 6, Status: "healthy", ActionPath: "/administration/identity-access/clients"),
|
||||
new AdministrationFacetTabDto("roles", "Roles", Count: 18, Status: "healthy", ActionPath: "/administration/identity-access/roles"),
|
||||
new AdministrationFacetTabDto("tenants", "Tenants", Count: 4, Status: "healthy", ActionPath: "/administration/identity-access/tenants"),
|
||||
new AdministrationFacetTabDto("users", "Users", Count: 146, Status: "healthy", ActionPath: "/administration/identity-access/users"),
|
||||
}.OrderBy(tab => tab.TabId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var actors = new[]
|
||||
{
|
||||
new IdentityAccessActorDto("alice@core.example", "release-approver", "active", "2026-02-19T01:22:00Z"),
|
||||
new IdentityAccessActorDto("jenkins-bot", "ci-bot", "active", "2026-02-19T02:17:00Z"),
|
||||
new IdentityAccessActorDto("security-admin@core.example", "security-admin", "active", "2026-02-19T02:59:00Z"),
|
||||
}.OrderBy(actor => actor.Actor, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/clients", "/administration/identity-access/clients", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/roles", "/administration/identity-access/roles", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/tenants", "/administration/identity-access/tenants", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/tokens", "/administration/identity-access/tokens", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/users", "/administration/identity-access/users", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationIdentityAccessDto(
|
||||
Tabs: tabs,
|
||||
RecentActors: actors,
|
||||
LegacyAliases: aliases,
|
||||
AuditLogPath: "/evidence-audit/audit");
|
||||
}
|
||||
|
||||
private static AdministrationTenantBrandingDto BuildAdministrationTenantBranding()
|
||||
{
|
||||
var tenants = new[]
|
||||
{
|
||||
new AdministrationTenantDto("apac-core", "Core APAC", "apac.core.example", "core-pack-v7", "active"),
|
||||
new AdministrationTenantDto("eu-core", "Core EU", "eu.core.example", "core-pack-v7", "active"),
|
||||
new AdministrationTenantDto("us-core", "Core US", "us.core.example", "core-pack-v7", "active"),
|
||||
}.OrderBy(tenant => tenant.TenantId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/branding", "/administration/tenant-branding", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationTenantBrandingDto(
|
||||
Tenants: tenants,
|
||||
BrandingDefaults: new TenantBrandingDefaultsDto(
|
||||
Theme: "light",
|
||||
SupportUrl: "https://support.core.example",
|
||||
LegalFooterVersion: "2026.02"),
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationNotificationsDto BuildAdministrationNotifications()
|
||||
{
|
||||
var rules = new[]
|
||||
{
|
||||
new AdministrationNotificationRuleDto("critical-reachable", "Critical reachable finding", "high", "active"),
|
||||
new AdministrationNotificationRuleDto("gate-blocked", "Gate blocked release", "high", "active"),
|
||||
new AdministrationNotificationRuleDto("quota-warning", "Quota warning", "medium", "active"),
|
||||
}.OrderBy(rule => rule.RuleId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var channels = new[]
|
||||
{
|
||||
new AdministrationNotificationChannelDto("email", "healthy", "2026-02-19T02:40:00Z"),
|
||||
new AdministrationNotificationChannelDto("slack", "healthy", "2026-02-19T02:41:00Z"),
|
||||
new AdministrationNotificationChannelDto("webhook", "warning", "2026-02-19T01:58:00Z"),
|
||||
}.OrderBy(channel => channel.ChannelId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/notifications", "/administration/notifications", "redirect"),
|
||||
new AdministrationRouteAliasDto("/operations/notifications", "/administration/notifications", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/notifications/*", "/administration/notifications/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationNotificationsDto(
|
||||
Rules: rules,
|
||||
Channels: channels,
|
||||
ChannelManagementPath: "/integrations/notifications",
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationUsageLimitsDto BuildAdministrationUsageLimits()
|
||||
{
|
||||
var meters = new[]
|
||||
{
|
||||
new AdministrationUsageMeterDto("api-calls", "API calls", Used: 15000, Limit: 100000, Unit: "calls"),
|
||||
new AdministrationUsageMeterDto("evidence-packets", "Evidence packets", Used: 2800, Limit: 10000, Unit: "packets"),
|
||||
new AdministrationUsageMeterDto("scanner-runs", "Scanner runs", Used: 6500, Limit: 10000, Unit: "runs"),
|
||||
new AdministrationUsageMeterDto("storage", "Storage", Used: 42, Limit: 100, Unit: "GB"),
|
||||
}.OrderBy(meter => meter.MeterId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var policies = new[]
|
||||
{
|
||||
new AdministrationUsagePolicyDto("api-burst", "API burst throttle", "enabled"),
|
||||
new AdministrationUsagePolicyDto("integration-cap", "Per-integration cap", "enabled"),
|
||||
new AdministrationUsagePolicyDto("scanner-quota", "Scanner daily quota", "warning"),
|
||||
}.OrderBy(policy => policy.PolicyId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/:page", "/administration/:page", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationUsageLimitsDto(
|
||||
Meters: meters,
|
||||
Policies: policies,
|
||||
OperationsDrilldownPath: "/platform-ops/quotas",
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationPolicyGovernanceDto BuildAdministrationPolicyGovernance()
|
||||
{
|
||||
var baselines = new[]
|
||||
{
|
||||
new AdministrationPolicyBaselineDto("dev", "core-pack-v7", "active"),
|
||||
new AdministrationPolicyBaselineDto("prod", "core-pack-v7", "active"),
|
||||
new AdministrationPolicyBaselineDto("stage", "core-pack-v7", "active"),
|
||||
}.OrderBy(baseline => baseline.Environment, StringComparer.Ordinal).ToList();
|
||||
|
||||
var signals = new[]
|
||||
{
|
||||
new AdministrationPolicySignalDto("exception-workflow", "warning", "2 pending exception approvals"),
|
||||
new AdministrationPolicySignalDto("governance-rules", "healthy", "Reachable-critical gate enforced"),
|
||||
new AdministrationPolicySignalDto("simulation", "healthy", "Last what-if simulation completed successfully"),
|
||||
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/policy/governance", "/administration/policy-governance", "redirect"),
|
||||
new AdministrationRouteAliasDto("/admin/policy/simulation", "/administration/policy-governance/simulation", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/exceptions/*", "/administration/policy-governance/exceptions/*", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/governance", "/administration/policy-governance", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/packs/*", "/administration/policy-governance/packs/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationPolicyGovernanceDto(
|
||||
Baselines: baselines,
|
||||
Signals: signals,
|
||||
LegacyAliases: aliases,
|
||||
CrossLinks:
|
||||
[
|
||||
"/release-control/approvals",
|
||||
"/administration/policy/exceptions",
|
||||
]);
|
||||
}
|
||||
|
||||
private static AdministrationTrustSigningDto BuildAdministrationTrustSigning()
|
||||
{
|
||||
var signals = new[]
|
||||
{
|
||||
new AdministrationTrustSignalDto("audit-log", "healthy", "Audit log ingestion is current."),
|
||||
new AdministrationTrustSignalDto("certificate-expiry", "warning", "1 certificate expires within 10 days."),
|
||||
new AdministrationTrustSignalDto("transparency-log", "healthy", "Rekor witness is reachable."),
|
||||
new AdministrationTrustSignalDto("trust-scoring", "healthy", "Issuer trust score recalculation completed."),
|
||||
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/issuers", "/administration/trust-signing/issuers", "redirect"),
|
||||
new AdministrationRouteAliasDto("/admin/trust/*", "/administration/trust-signing/*", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/trust/*", "/administration/trust-signing/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationTrustSigningDto(
|
||||
Inventory: new AdministrationTrustInventoryDto(Keys: 14, Issuers: 7, Certificates: 23),
|
||||
Signals: signals,
|
||||
LegacyAliases: aliases,
|
||||
EvidenceConsumerPath: "/evidence-audit/proofs");
|
||||
}
|
||||
|
||||
private static AdministrationSystemDto BuildAdministrationSystem()
|
||||
{
|
||||
var controls = new[]
|
||||
{
|
||||
new AdministrationSystemControlDto("background-jobs", "warning", "1 paused job family awaiting manual resume."),
|
||||
new AdministrationSystemControlDto("doctor", "healthy", "Last diagnostics run passed."),
|
||||
new AdministrationSystemControlDto("health-check", "healthy", "All core control-plane services are healthy."),
|
||||
new AdministrationSystemControlDto("slo-config", "healthy", "SLO thresholds are synchronized."),
|
||||
}.OrderBy(control => control.ControlId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/operations/status", "/administration/system/status", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/configuration-pane", "/administration/system/configuration", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/workflows/*", "/administration/system/workflows", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationSystemDto(
|
||||
OverallStatus: "healthy",
|
||||
Controls: controls,
|
||||
LegacyAliases: aliases,
|
||||
Drilldowns:
|
||||
[
|
||||
"/platform-ops/health",
|
||||
"/platform-ops/orchestrator/jobs",
|
||||
"/platform-ops/data-integrity",
|
||||
]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdministrationRouteAliasDto> BuildAdministrationAliases(
|
||||
AdministrationRouteAliasDto[] aliases)
|
||||
{
|
||||
return aliases
|
||||
.OrderBy(alias => alias.LegacyPath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static DataConfidenceBadgeDto BuildConfidenceBadge()
|
||||
{
|
||||
return new DataConfidenceBadgeDto(
|
||||
Status: "warning",
|
||||
Summary: "NVD freshness and runtime ingest lag reduce confidence.",
|
||||
NvdStalenessHours: 3,
|
||||
StaleSbomDigests: 12,
|
||||
RuntimeDlqDepth: 1230);
|
||||
}
|
||||
|
||||
private static IResult BuildAdministrationItem<T>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
Func<T> payloadFactory)
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = payloadFactory();
|
||||
return Results.Ok(new PlatformItemResponse<T>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DashboardSummaryDto(
|
||||
DataConfidenceBadgeDto DataConfidence,
|
||||
int EnvironmentsWithCriticalReachable,
|
||||
int TotalCriticalReachable,
|
||||
decimal SbomCoveragePercent,
|
||||
decimal VexCoveragePercent,
|
||||
int BlockedApprovals,
|
||||
int ExceptionsExpiringSoon,
|
||||
IReadOnlyList<EnvironmentRiskSnapshotDto> EnvironmentRisk,
|
||||
IReadOnlyList<DashboardDriverDto> TopDrivers);
|
||||
|
||||
public sealed record EnvironmentRiskSnapshotDto(
|
||||
string Environment,
|
||||
int CriticalReachable,
|
||||
int HighReachable,
|
||||
string SbomState);
|
||||
|
||||
public sealed record DashboardDriverDto(
|
||||
string Cve,
|
||||
string Component,
|
||||
string Severity,
|
||||
string Reachability);
|
||||
|
||||
public sealed record DataIntegritySummaryDto(
|
||||
DataConfidenceBadgeDto Confidence,
|
||||
IReadOnlyList<DataIntegritySignalDto> Signals);
|
||||
|
||||
public sealed record DataIntegritySignalDto(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Summary,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record DataIntegrityReportDto(
|
||||
string ReportId,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string Window,
|
||||
IReadOnlyList<DataIntegrityReportSectionDto> Sections,
|
||||
IReadOnlyList<string> RecommendedActions);
|
||||
|
||||
public sealed record DataIntegrityReportSectionDto(
|
||||
string SectionId,
|
||||
string Status,
|
||||
string Summary,
|
||||
IReadOnlyList<string> Highlights);
|
||||
|
||||
public sealed record FeedFreshnessDto(
|
||||
string Source,
|
||||
string Status,
|
||||
string LastSyncedAt,
|
||||
int FreshnessHours,
|
||||
int SlaHours);
|
||||
|
||||
public sealed record ScanPipelineHealthDto(
|
||||
string Status,
|
||||
int PendingDigests,
|
||||
int FailedJobs24h,
|
||||
IReadOnlyList<PipelineStageHealthDto> Stages);
|
||||
|
||||
public sealed record PipelineStageHealthDto(
|
||||
string Stage,
|
||||
string Status,
|
||||
int QueueDepth,
|
||||
int OldestAgeMinutes);
|
||||
|
||||
public sealed record ReachabilityIngestHealthDto(
|
||||
string Status,
|
||||
int RuntimeCoveragePercent,
|
||||
IReadOnlyList<RegionIngestHealthDto> Regions);
|
||||
|
||||
public sealed record RegionIngestHealthDto(
|
||||
string Region,
|
||||
string Status,
|
||||
int Backlog,
|
||||
int FreshnessMinutes);
|
||||
|
||||
public sealed record AdministrationSummaryDto(
|
||||
IReadOnlyList<AdministrationDomainCardDto> Domains,
|
||||
IReadOnlyList<string> ActiveIncidents);
|
||||
|
||||
public sealed record AdministrationDomainCardDto(
|
||||
string DomainId,
|
||||
string Label,
|
||||
string Status,
|
||||
string Summary,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record AdministrationIdentityAccessDto(
|
||||
IReadOnlyList<AdministrationFacetTabDto> Tabs,
|
||||
IReadOnlyList<IdentityAccessActorDto> RecentActors,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
string AuditLogPath);
|
||||
|
||||
public sealed record AdministrationFacetTabDto(
|
||||
string TabId,
|
||||
string Label,
|
||||
int Count,
|
||||
string Status,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record IdentityAccessActorDto(
|
||||
string Actor,
|
||||
string Role,
|
||||
string Status,
|
||||
string LastSeenAt);
|
||||
|
||||
public sealed record AdministrationTenantBrandingDto(
|
||||
IReadOnlyList<AdministrationTenantDto> Tenants,
|
||||
TenantBrandingDefaultsDto BrandingDefaults,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationTenantDto(
|
||||
string TenantId,
|
||||
string DisplayName,
|
||||
string PrimaryDomain,
|
||||
string DefaultPolicyPack,
|
||||
string Status);
|
||||
|
||||
public sealed record TenantBrandingDefaultsDto(
|
||||
string Theme,
|
||||
string SupportUrl,
|
||||
string LegalFooterVersion);
|
||||
|
||||
public sealed record AdministrationNotificationsDto(
|
||||
IReadOnlyList<AdministrationNotificationRuleDto> Rules,
|
||||
IReadOnlyList<AdministrationNotificationChannelDto> Channels,
|
||||
string ChannelManagementPath,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationNotificationRuleDto(
|
||||
string RuleId,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationNotificationChannelDto(
|
||||
string ChannelId,
|
||||
string Status,
|
||||
string LastDeliveredAt);
|
||||
|
||||
public sealed record AdministrationUsageLimitsDto(
|
||||
IReadOnlyList<AdministrationUsageMeterDto> Meters,
|
||||
IReadOnlyList<AdministrationUsagePolicyDto> Policies,
|
||||
string OperationsDrilldownPath,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationUsageMeterDto(
|
||||
string MeterId,
|
||||
string Label,
|
||||
int Used,
|
||||
int Limit,
|
||||
string Unit);
|
||||
|
||||
public sealed record AdministrationUsagePolicyDto(
|
||||
string PolicyId,
|
||||
string Label,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationPolicyGovernanceDto(
|
||||
IReadOnlyList<AdministrationPolicyBaselineDto> Baselines,
|
||||
IReadOnlyList<AdministrationPolicySignalDto> Signals,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
IReadOnlyList<string> CrossLinks);
|
||||
|
||||
public sealed record AdministrationPolicyBaselineDto(
|
||||
string Environment,
|
||||
string PolicyPack,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationPolicySignalDto(
|
||||
string SignalId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationTrustSigningDto(
|
||||
AdministrationTrustInventoryDto Inventory,
|
||||
IReadOnlyList<AdministrationTrustSignalDto> Signals,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
string EvidenceConsumerPath);
|
||||
|
||||
public sealed record AdministrationTrustInventoryDto(
|
||||
int Keys,
|
||||
int Issuers,
|
||||
int Certificates);
|
||||
|
||||
public sealed record AdministrationTrustSignalDto(
|
||||
string SignalId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationSystemDto(
|
||||
string OverallStatus,
|
||||
IReadOnlyList<AdministrationSystemControlDto> Controls,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
IReadOnlyList<string> Drilldowns);
|
||||
|
||||
public sealed record AdministrationSystemControlDto(
|
||||
string ControlId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationRouteAliasDto(
|
||||
string LegacyPath,
|
||||
string CanonicalPath,
|
||||
string Action);
|
||||
|
||||
public sealed record DataConfidenceBadgeDto(
|
||||
string Status,
|
||||
string Summary,
|
||||
int NvdStalenessHours,
|
||||
int StaleSbomDigests,
|
||||
int RuntimeDlqDepth);
|
||||
@@ -0,0 +1,330 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release Control bundle lifecycle endpoints consumed by UI v2 shell.
|
||||
/// </summary>
|
||||
public static class ReleaseControlEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapReleaseControlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var bundles = app.MapGroup("/api/v1/release-control/bundles")
|
||||
.WithTags("Release Control");
|
||||
|
||||
bundles.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
var items = await store.ListBundlesAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseControlBundleSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListReleaseControlBundles")
|
||||
.WithSummary("List release control bundles")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await store.GetBundleAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "bundle_not_found", bundleId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ReleaseControlBundleDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseControlBundle")
|
||||
.WithSummary("Get release control bundle by id")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapPost(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.CreateBundleAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{created.Id}";
|
||||
return Results.Created(location, created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId: null, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("CreateReleaseControlBundle")
|
||||
.WithSummary("Create release control bundle")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}/versions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
try
|
||||
{
|
||||
var items = await store.ListVersionsAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseControlBundleVersionSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("ListReleaseControlBundleVersions")
|
||||
.WithSummary("List bundle versions")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}/versions/{versionId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var version = await store.GetVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
versionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (version is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ReleaseControlBundleVersionDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
version));
|
||||
})
|
||||
.WithName("GetReleaseControlBundleVersion")
|
||||
.WithSummary("Get bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapPost("/{bundleId:guid}/versions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.PublishVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
bundleId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{created.Id}";
|
||||
return Results.Created(location, created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("PublishReleaseControlBundleVersion")
|
||||
.WithSummary("Publish immutable bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
bundles.MapPost("/{bundleId:guid}/versions/{versionId:guid}/materialize", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var run = await store.MaterializeVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
bundleId,
|
||||
versionId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{versionId}/materialize/{run.RunId}";
|
||||
return Results.Accepted(location, run);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId);
|
||||
}
|
||||
})
|
||||
.WithName("MaterializeReleaseControlBundleVersion")
|
||||
.WithSummary("Materialize bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
|
||||
private static IResult MapStoreError(InvalidOperationException exception, Guid? bundleId, Guid? versionId)
|
||||
{
|
||||
return exception.Message switch
|
||||
{
|
||||
"bundle_not_found" => Results.NotFound(new { error = "bundle_not_found", bundleId }),
|
||||
"bundle_version_not_found" => Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId }),
|
||||
"bundle_slug_exists" => Results.Conflict(new { error = "bundle_slug_exists" }),
|
||||
"bundle_slug_required" => Results.BadRequest(new { error = "bundle_slug_required" }),
|
||||
"bundle_name_required" => Results.BadRequest(new { error = "bundle_name_required" }),
|
||||
"request_required" => Results.BadRequest(new { error = "request_required" }),
|
||||
"tenant_required" => Results.BadRequest(new { error = "tenant_required" }),
|
||||
"tenant_id_invalid" => Results.BadRequest(new { error = "tenant_id_invalid" }),
|
||||
_ => Results.BadRequest(new { error = exception.Message })
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using StellaOps.Telemetry.Federation;
|
||||
using System;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -63,6 +64,11 @@ builder.Services.AddStellaOpsTelemetry(
|
||||
});
|
||||
|
||||
builder.Services.AddTelemetryContextPropagation();
|
||||
builder.Services.AddFederatedTelemetry(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("Platform:Federation").Bind(options);
|
||||
});
|
||||
builder.Services.AddFederatedTelemetrySync();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
@@ -122,6 +128,9 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupRead, PlatformScopes.SetupRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupWrite, PlatformScopes.SetupWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupAdmin, PlatformScopes.SetupAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustRead, PlatformScopes.TrustRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustWrite, PlatformScopes.TrustWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustAdmin, PlatformScopes.TrustAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreRead, PlatformScopes.ScoreRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreEvaluate, PlatformScopes.ScoreEvaluate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FunctionMapRead, PlatformScopes.FunctionMapRead);
|
||||
@@ -130,6 +139,10 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyRead, PlatformScopes.PolicyRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyWrite, PlatformScopes.PolicyWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyEvaluate, PlatformScopes.PolicyEvaluate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ReleaseControlRead, PlatformScopes.OrchRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ReleaseControlOperate, PlatformScopes.OrchOperate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationRead, PlatformScopes.FederationRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationManage, PlatformScopes.FederationManage);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -177,11 +190,15 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, InMemoryEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, InMemoryReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
|
||||
}
|
||||
|
||||
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
|
||||
@@ -233,6 +250,10 @@ app.MapAnalyticsEndpoints();
|
||||
app.MapScoreEndpoints();
|
||||
app.MapFunctionMapEndpoints();
|
||||
app.MapPolicyInteropEndpoints();
|
||||
app.MapReleaseControlEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public interface IAdministrationTrustSigningStore
|
||||
{
|
||||
Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public interface IReleaseControlBundleStore
|
||||
{
|
||||
Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class InMemoryAdministrationTrustSigningStore : IAdministrationTrustSigningStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryAdministrationTrustSigningStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Keys.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.Alias, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.KeyId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustKeySummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var alias = NormalizeRequired(request.Alias, "key_alias_required");
|
||||
var algorithm = NormalizeRequired(request.Algorithm, "key_algorithm_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var duplicateAlias = state.Keys.Values.Any(existing =>
|
||||
string.Equals(existing.Alias, alias, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateAlias)
|
||||
{
|
||||
throw new InvalidOperationException("key_alias_exists");
|
||||
}
|
||||
|
||||
var created = new KeyState
|
||||
{
|
||||
KeyId = Guid.NewGuid(),
|
||||
Alias = alias,
|
||||
Algorithm = algorithm,
|
||||
Status = "active",
|
||||
CurrentVersion = 1,
|
||||
MetadataJson = NormalizeOptional(request.MetadataJson) ?? "{}",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Keys[created.KeyId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Keys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (string.Equals(key.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("key_revoked");
|
||||
}
|
||||
|
||||
key.CurrentVersion += 1;
|
||||
key.Status = "active";
|
||||
key.UpdatedAt = now;
|
||||
key.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(key));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Keys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
key.Status = "revoked";
|
||||
key.UpdatedAt = now;
|
||||
key.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(key));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Issuers.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.IssuerId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustIssuerSummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var name = NormalizeRequired(request.Name, "issuer_name_required");
|
||||
var issuerUri = NormalizeRequired(request.IssuerUri, "issuer_uri_required");
|
||||
var trustLevel = NormalizeRequired(request.TrustLevel, "issuer_trust_level_required");
|
||||
ValidateAbsoluteUri(issuerUri, "issuer_uri_invalid");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var duplicateIssuer = state.Issuers.Values.Any(existing =>
|
||||
string.Equals(existing.IssuerUri, issuerUri, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateIssuer)
|
||||
{
|
||||
throw new InvalidOperationException("issuer_uri_exists");
|
||||
}
|
||||
|
||||
var created = new IssuerState
|
||||
{
|
||||
IssuerId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
IssuerUri = issuerUri,
|
||||
TrustLevel = trustLevel,
|
||||
Status = "active",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Issuers[created.IssuerId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Certificates.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.SerialNumber, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.CertificateId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustCertificateSummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var serialNumber = NormalizeRequired(request.SerialNumber, "certificate_serial_required");
|
||||
if (request.NotAfter <= request.NotBefore)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_validity_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (request.KeyId.HasValue && !state.Keys.ContainsKey(request.KeyId.Value))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (request.IssuerId.HasValue && !state.Issuers.ContainsKey(request.IssuerId.Value))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
var duplicateSerial = state.Certificates.Values.Any(existing =>
|
||||
string.Equals(existing.SerialNumber, serialNumber, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateSerial)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_serial_exists");
|
||||
}
|
||||
|
||||
var created = new CertificateState
|
||||
{
|
||||
CertificateId = Guid.NewGuid(),
|
||||
KeyId = request.KeyId,
|
||||
IssuerId = request.IssuerId,
|
||||
SerialNumber = serialNumber,
|
||||
Status = "active",
|
||||
NotBefore = request.NotBefore,
|
||||
NotAfter = request.NotAfter,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Certificates[created.CertificateId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Certificates.TryGetValue(certificateId, out var certificate))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_not_found");
|
||||
}
|
||||
|
||||
certificate.Status = "revoked";
|
||||
certificate.UpdatedAt = now;
|
||||
certificate.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(certificate));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(state.TransparencyConfig is null ? null : ToSummary(state.TransparencyConfig));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var logUrl = NormalizeRequired(request.LogUrl, "transparency_log_url_required");
|
||||
ValidateAbsoluteUri(logUrl, "transparency_log_url_invalid");
|
||||
|
||||
var witnessUrl = NormalizeOptional(request.WitnessUrl);
|
||||
if (!string.IsNullOrWhiteSpace(witnessUrl))
|
||||
{
|
||||
ValidateAbsoluteUri(witnessUrl, "transparency_witness_url_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
state.TransparencyConfig = new TransparencyConfigState
|
||||
{
|
||||
LogUrl = logUrl,
|
||||
WitnessUrl = witnessUrl,
|
||||
EnforceInclusion = request.EnforceInclusion,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
return Task.FromResult(ToSummary(state.TransparencyConfig));
|
||||
}
|
||||
}
|
||||
|
||||
private TenantState GetState(string tenantId)
|
||||
{
|
||||
var tenant = NormalizeRequired(tenantId, "tenant_required").ToLowerInvariant();
|
||||
return _states.GetOrAdd(tenant, _ => new TenantState());
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
|
||||
|
||||
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;
|
||||
|
||||
private static void ValidateAbsoluteUri(string value, string errorCode)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdministrationTrustKeySummary ToSummary(KeyState state)
|
||||
{
|
||||
return new AdministrationTrustKeySummary(
|
||||
state.KeyId,
|
||||
state.Alias,
|
||||
state.Algorithm,
|
||||
state.Status,
|
||||
state.CurrentVersion,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTrustIssuerSummary ToSummary(IssuerState state)
|
||||
{
|
||||
return new AdministrationTrustIssuerSummary(
|
||||
state.IssuerId,
|
||||
state.Name,
|
||||
state.IssuerUri,
|
||||
state.TrustLevel,
|
||||
state.Status,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTrustCertificateSummary ToSummary(CertificateState state)
|
||||
{
|
||||
return new AdministrationTrustCertificateSummary(
|
||||
state.CertificateId,
|
||||
state.KeyId,
|
||||
state.IssuerId,
|
||||
state.SerialNumber,
|
||||
state.Status,
|
||||
state.NotBefore,
|
||||
state.NotAfter,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTransparencyLogConfig ToSummary(TransparencyConfigState state)
|
||||
{
|
||||
return new AdministrationTransparencyLogConfig(
|
||||
state.LogUrl,
|
||||
state.WitnessUrl,
|
||||
state.EnforceInclusion,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private sealed class TenantState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
|
||||
public Dictionary<Guid, KeyState> Keys { get; } = new();
|
||||
|
||||
public Dictionary<Guid, IssuerState> Issuers { get; } = new();
|
||||
|
||||
public Dictionary<Guid, CertificateState> Certificates { get; } = new();
|
||||
|
||||
public TransparencyConfigState? TransparencyConfig { get; set; }
|
||||
}
|
||||
|
||||
private sealed class KeyState
|
||||
{
|
||||
public Guid KeyId { get; set; }
|
||||
|
||||
public string Alias { get; set; } = string.Empty;
|
||||
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public int CurrentVersion { get; set; }
|
||||
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class IssuerState
|
||||
{
|
||||
public Guid IssuerId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string IssuerUri { get; set; } = string.Empty;
|
||||
|
||||
public string TrustLevel { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class CertificateState
|
||||
{
|
||||
public Guid CertificateId { get; set; }
|
||||
|
||||
public Guid? KeyId { get; set; }
|
||||
|
||||
public Guid? IssuerId { get; set; }
|
||||
|
||||
public string SerialNumber { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset NotBefore { get; set; }
|
||||
|
||||
public DateTimeOffset NotAfter { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class TransparencyConfigState
|
||||
{
|
||||
public string LogUrl { get; set; } = string.Empty;
|
||||
|
||||
public string? WitnessUrl { get; set; }
|
||||
|
||||
public bool EnforceInclusion { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class InMemoryReleaseControlBundleStore : IReleaseControlBundleStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryReleaseControlBundleStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var list = state.Bundles.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(bundle => bundle.Name, StringComparer.Ordinal)
|
||||
.ThenBy(bundle => bundle.Id)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleSummary>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(
|
||||
state.Bundles.TryGetValue(bundleId, out var bundle)
|
||||
? ToDetail(bundle)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var slug = NormalizeSlug(request.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_name_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var exists = state.Bundles.Values.Any(bundle =>
|
||||
string.Equals(bundle.Slug, slug, StringComparison.OrdinalIgnoreCase));
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_exists");
|
||||
}
|
||||
|
||||
var created = new BundleState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = slug,
|
||||
Name = request.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = string.IsNullOrWhiteSpace(actorId) ? "system" : actorId
|
||||
};
|
||||
|
||||
state.Bundles[created.Id] = created;
|
||||
return Task.FromResult(ToDetail(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var list = bundle.Versions
|
||||
.OrderByDescending(version => version.VersionNumber)
|
||||
.ThenByDescending(version => version.Id)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.Select(ToVersionSummary)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleVersionSummary>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
return Task.FromResult<ReleaseControlBundleVersionDetail?>(null);
|
||||
}
|
||||
|
||||
var version = bundle.Versions.FirstOrDefault(item => item.Id == versionId);
|
||||
return Task.FromResult(version is null ? null : ToVersionDetail(version));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var nextVersion = bundle.Versions.Count == 0
|
||||
? 1
|
||||
: bundle.Versions.Max(version => version.VersionNumber) + 1;
|
||||
|
||||
var digest = ReleaseControlBundleDigest.Compute(
|
||||
bundleId,
|
||||
nextVersion,
|
||||
request.Changelog,
|
||||
normalizedComponents);
|
||||
|
||||
var version = new BundleVersionState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BundleId = bundleId,
|
||||
VersionNumber = nextVersion,
|
||||
Digest = digest,
|
||||
Status = "published",
|
||||
ComponentsCount = normalizedComponents.Count,
|
||||
Changelog = string.IsNullOrWhiteSpace(request.Changelog) ? null : request.Changelog.Trim(),
|
||||
CreatedAt = now,
|
||||
PublishedAt = now,
|
||||
CreatedBy = string.IsNullOrWhiteSpace(actorId) ? "system" : actorId,
|
||||
Components = normalizedComponents
|
||||
.Select(component => new ReleaseControlBundleComponent(
|
||||
component.ComponentVersionId,
|
||||
component.ComponentName,
|
||||
component.ImageDigest,
|
||||
component.DeployOrder,
|
||||
component.MetadataJson ?? "{}"))
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
bundle.Versions.Add(version);
|
||||
bundle.UpdatedAt = now;
|
||||
return Task.FromResult(ToVersionDetail(version));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var versionExists = bundle.Versions.Any(version => version.Id == versionId);
|
||||
if (!versionExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
var normalizedKey = NormalizeIdempotencyKey(request.IdempotencyKey);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKey))
|
||||
{
|
||||
var existing = state.Materializations.Values.FirstOrDefault(run =>
|
||||
run.BundleId == bundleId
|
||||
&& run.VersionId == versionId
|
||||
&& string.Equals(run.IdempotencyKey, normalizedKey, StringComparison.Ordinal));
|
||||
if (existing is not null)
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
}
|
||||
|
||||
var created = new ReleaseControlBundleMaterializationRun(
|
||||
Guid.NewGuid(),
|
||||
bundleId,
|
||||
versionId,
|
||||
"queued",
|
||||
NormalizeOptional(request.TargetEnvironment),
|
||||
NormalizeOptional(request.Reason),
|
||||
string.IsNullOrWhiteSpace(actorId) ? "system" : actorId,
|
||||
normalizedKey,
|
||||
now,
|
||||
now);
|
||||
|
||||
state.Materializations[created.RunId] = created;
|
||||
bundle.UpdatedAt = now;
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantState GetState(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
return _states.GetOrAdd(tenantId.Trim().ToLowerInvariant(), _ => new TenantState());
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleSummary ToSummary(BundleState bundle)
|
||||
{
|
||||
var latest = bundle.Versions
|
||||
.OrderByDescending(version => version.VersionNumber)
|
||||
.ThenByDescending(version => version.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ReleaseControlBundleSummary(
|
||||
bundle.Id,
|
||||
bundle.Slug,
|
||||
bundle.Name,
|
||||
bundle.Description,
|
||||
bundle.Versions.Count,
|
||||
latest?.VersionNumber,
|
||||
latest?.Id,
|
||||
latest?.Digest,
|
||||
latest?.PublishedAt,
|
||||
bundle.CreatedAt,
|
||||
bundle.UpdatedAt);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleDetail ToDetail(BundleState bundle)
|
||||
{
|
||||
var summary = ToSummary(bundle);
|
||||
return new ReleaseControlBundleDetail(
|
||||
summary.Id,
|
||||
summary.Slug,
|
||||
summary.Name,
|
||||
summary.Description,
|
||||
summary.TotalVersions,
|
||||
summary.LatestVersionNumber,
|
||||
summary.LatestVersionId,
|
||||
summary.LatestVersionDigest,
|
||||
summary.LatestPublishedAt,
|
||||
summary.CreatedAt,
|
||||
summary.UpdatedAt,
|
||||
bundle.CreatedBy);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionSummary ToVersionSummary(BundleVersionState version)
|
||||
{
|
||||
return new ReleaseControlBundleVersionSummary(
|
||||
version.Id,
|
||||
version.BundleId,
|
||||
version.VersionNumber,
|
||||
version.Digest,
|
||||
version.Status,
|
||||
version.ComponentsCount,
|
||||
version.Changelog,
|
||||
version.CreatedAt,
|
||||
version.PublishedAt,
|
||||
version.CreatedBy);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionDetail ToVersionDetail(BundleVersionState version)
|
||||
{
|
||||
var summary = ToVersionSummary(version);
|
||||
return new ReleaseControlBundleVersionDetail(
|
||||
summary.Id,
|
||||
summary.BundleId,
|
||||
summary.VersionNumber,
|
||||
summary.Digest,
|
||||
summary.Status,
|
||||
summary.ComponentsCount,
|
||||
summary.Changelog,
|
||||
summary.CreatedAt,
|
||||
summary.PublishedAt,
|
||||
summary.CreatedBy,
|
||||
version.Components);
|
||||
}
|
||||
|
||||
private static string NormalizeSlug(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Trim().ToLowerInvariant();
|
||||
var chars = cleaned
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray();
|
||||
var compact = new string(chars);
|
||||
while (compact.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
compact = compact.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return compact.Trim('-');
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeIdempotencyKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private sealed class TenantState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
public Dictionary<Guid, BundleState> Bundles { get; } = new();
|
||||
public Dictionary<Guid, ReleaseControlBundleMaterializationRun> Materializations { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class BundleState
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string CreatedBy { get; init; } = string.Empty;
|
||||
public List<BundleVersionState> Versions { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class BundleVersionState
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid BundleId { get; init; }
|
||||
public int VersionNumber { get; init; }
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "published";
|
||||
public int ComponentsCount { get; init; }
|
||||
public string? Changelog { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public string CreatedBy { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ReleaseControlBundleComponent> Components { get; init; } = Array.Empty<ReleaseControlBundleComponent>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed trust and signing administration store.
|
||||
/// </summary>
|
||||
public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTrustSigningStore
|
||||
{
|
||||
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresAdministrationTrustSigningStore> _logger;
|
||||
|
||||
public PostgresAdministrationTrustSigningStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresAdministrationTrustSigningStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresAdministrationTrustSigningStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY key_alias, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustKeySummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapKeySummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var alias = NormalizeRequired(request.Alias, "key_alias_required");
|
||||
var algorithm = NormalizeRequired(request.Algorithm, "key_algorithm_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var keyId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_keys (
|
||||
id,
|
||||
tenant_id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@key_alias,
|
||||
@algorithm,
|
||||
'active',
|
||||
1,
|
||||
@metadata_json::jsonb,
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("key_alias", alias);
|
||||
command.Parameters.AddWithValue("algorithm", algorithm);
|
||||
command.Parameters.AddWithValue("metadata_json", NormalizeOptional(request.MetadataJson) ?? "{}");
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_create_failed");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("key_alias_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
var existingStatus = await GetKeyStatusAsync(connection, tenantGuid, keyId, cancellationToken).ConfigureAwait(false);
|
||||
if (existingStatus is null)
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (string.Equals(existingStatus, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("key_revoked");
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_keys
|
||||
SET
|
||||
current_version = current_version + 1,
|
||||
status = 'active',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_keys
|
||||
SET
|
||||
status = 'revoked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_issuers
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY issuer_name, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustIssuerSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapIssuerSummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var name = NormalizeRequired(request.Name, "issuer_name_required");
|
||||
var issuerUri = NormalizeRequired(request.IssuerUri, "issuer_uri_required");
|
||||
ValidateAbsoluteUri(issuerUri, "issuer_uri_invalid");
|
||||
var trustLevel = NormalizeRequired(request.TrustLevel, "issuer_trust_level_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var issuerId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_issuers (
|
||||
id,
|
||||
tenant_id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@issuer_name,
|
||||
@issuer_uri,
|
||||
@trust_level,
|
||||
'active',
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("issuer_name", name);
|
||||
command.Parameters.AddWithValue("issuer_uri", issuerUri);
|
||||
command.Parameters.AddWithValue("trust_level", trustLevel);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_create_failed");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_uri_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_certificates
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY serial_number, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustCertificateSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapCertificateSummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var serialNumber = NormalizeRequired(request.SerialNumber, "certificate_serial_required");
|
||||
if (request.NotAfter <= request.NotBefore)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_validity_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var certificateId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.KeyId.HasValue)
|
||||
{
|
||||
var keyExists = await EntityExistsAsync(
|
||||
connection,
|
||||
"release.trust_keys",
|
||||
tenantGuid,
|
||||
request.KeyId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!keyExists)
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.IssuerId.HasValue)
|
||||
{
|
||||
var issuerExists = await EntityExistsAsync(
|
||||
connection,
|
||||
"release.trust_issuers",
|
||||
tenantGuid,
|
||||
request.IssuerId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!issuerExists)
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@key_id,
|
||||
@issuer_id,
|
||||
@serial_number,
|
||||
'active',
|
||||
@not_before,
|
||||
@not_after,
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", certificateId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("key_id", (object?)request.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("issuer_id", (object?)request.IssuerId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("serial_number", serialNumber);
|
||||
command.Parameters.AddWithValue("not_before", request.NotBefore);
|
||||
command.Parameters.AddWithValue("not_after", request.NotAfter);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_create_failed");
|
||||
}
|
||||
|
||||
return MapCertificateSummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_serial_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_certificates
|
||||
SET
|
||||
status = 'revoked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", certificateId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_not_found");
|
||||
}
|
||||
|
||||
return MapCertificateSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_transparency_configs
|
||||
WHERE tenant_id = @tenant_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapTransparencyConfig(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var logUrl = NormalizeRequired(request.LogUrl, "transparency_log_url_required");
|
||||
ValidateAbsoluteUri(logUrl, "transparency_log_url_invalid");
|
||||
var witnessUrl = NormalizeOptional(request.WitnessUrl);
|
||||
if (!string.IsNullOrWhiteSpace(witnessUrl))
|
||||
{
|
||||
ValidateAbsoluteUri(witnessUrl, "transparency_witness_url_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_transparency_configs (
|
||||
tenant_id,
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@log_url,
|
||||
@witness_url,
|
||||
@enforce_inclusion,
|
||||
@updated_at,
|
||||
@updated_by
|
||||
)
|
||||
ON CONFLICT (tenant_id)
|
||||
DO UPDATE SET
|
||||
log_url = EXCLUDED.log_url,
|
||||
witness_url = EXCLUDED.witness_url,
|
||||
enforce_inclusion = EXCLUDED.enforce_inclusion,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("log_url", logUrl);
|
||||
command.Parameters.AddWithValue("witness_url", (object?)witnessUrl ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("enforce_inclusion", request.EnforceInclusion);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("transparency_log_update_failed");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Configured trust transparency log for tenant {TenantId}", tenantGuid);
|
||||
return MapTransparencyConfig(reader);
|
||||
}
|
||||
|
||||
private async Task<string?> GetKeyStatusAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT status
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
private static async Task<bool> EntityExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tableName,
|
||||
Guid tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT 1 FROM {tableName} WHERE tenant_id = @tenant_id AND id = @id LIMIT 1",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var setTenantCommand = new NpgsqlCommand(SetTenantSql, connection);
|
||||
setTenantCommand.Parameters.AddWithValue("tenant_id", tenantId.ToString("D"));
|
||||
await setTenantCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_id_invalid");
|
||||
}
|
||||
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
|
||||
|
||||
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static void ValidateAbsoluteUri(string value, string errorCode)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdministrationTrustKeySummary MapKeySummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustKeySummary(
|
||||
KeyId: reader.GetGuid(0),
|
||||
Alias: reader.GetString(1),
|
||||
Algorithm: reader.GetString(2),
|
||||
Status: reader.GetString(3),
|
||||
CurrentVersion: reader.GetInt32(4),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedBy: reader.GetString(7));
|
||||
}
|
||||
|
||||
private static AdministrationTrustIssuerSummary MapIssuerSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustIssuerSummary(
|
||||
IssuerId: reader.GetGuid(0),
|
||||
Name: reader.GetString(1),
|
||||
IssuerUri: reader.GetString(2),
|
||||
TrustLevel: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedBy: reader.GetString(7));
|
||||
}
|
||||
|
||||
private static AdministrationTrustCertificateSummary MapCertificateSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustCertificateSummary(
|
||||
CertificateId: reader.GetGuid(0),
|
||||
KeyId: reader.IsDBNull(1) ? null : reader.GetGuid(1),
|
||||
IssuerId: reader.IsDBNull(2) ? null : reader.GetGuid(2),
|
||||
SerialNumber: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
NotBefore: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
NotAfter: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedBy: reader.GetString(9));
|
||||
}
|
||||
|
||||
private static AdministrationTransparencyLogConfig MapTransparencyConfig(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTransparencyLogConfig(
|
||||
LogUrl: reader.GetString(0),
|
||||
WitnessUrl: reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
EnforceInclusion: reader.GetBoolean(2),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(3),
|
||||
UpdatedBy: reader.GetString(4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed release control bundle store.
|
||||
/// </summary>
|
||||
public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleStore
|
||||
{
|
||||
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresReleaseControlBundleStore> _logger;
|
||||
|
||||
public PostgresReleaseControlBundleStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresReleaseControlBundleStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresReleaseControlBundleStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
b.id,
|
||||
b.slug,
|
||||
b.name,
|
||||
b.description,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
COALESCE(v.total_versions, 0) AS total_versions,
|
||||
lv.version_number,
|
||||
lv.id AS latest_version_id,
|
||||
lv.digest,
|
||||
lv.published_at
|
||||
FROM release.control_bundles b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total_versions
|
||||
FROM release.control_bundle_versions v
|
||||
WHERE v.tenant_id = b.tenant_id AND v.bundle_id = b.id
|
||||
) v ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, version_number, digest, published_at
|
||||
FROM release.control_bundle_versions v2
|
||||
WHERE v2.tenant_id = b.tenant_id AND v2.bundle_id = b.id
|
||||
ORDER BY v2.version_number DESC, v2.id DESC
|
||||
LIMIT 1
|
||||
) lv ON true
|
||||
WHERE b.tenant_id = @tenant_id
|
||||
ORDER BY b.name, b.id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new ReleaseControlBundleSummary(
|
||||
Id: reader.GetGuid(0),
|
||||
Slug: reader.GetString(1),
|
||||
Name: reader.GetString(2),
|
||||
Description: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
TotalVersions: reader.GetInt32(6),
|
||||
LatestVersionNumber: reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
LatestVersionId: reader.IsDBNull(8) ? null : reader.GetGuid(8),
|
||||
LatestVersionDigest: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
LatestPublishedAt: reader.IsDBNull(10) ? null : reader.GetFieldValue<DateTimeOffset>(10),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(5)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
b.id,
|
||||
b.slug,
|
||||
b.name,
|
||||
b.description,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.created_by,
|
||||
COALESCE(v.total_versions, 0) AS total_versions,
|
||||
lv.version_number,
|
||||
lv.id AS latest_version_id,
|
||||
lv.digest,
|
||||
lv.published_at
|
||||
FROM release.control_bundles b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total_versions
|
||||
FROM release.control_bundle_versions v
|
||||
WHERE v.tenant_id = b.tenant_id AND v.bundle_id = b.id
|
||||
) v ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, version_number, digest, published_at
|
||||
FROM release.control_bundle_versions v2
|
||||
WHERE v2.tenant_id = b.tenant_id AND v2.bundle_id = b.id
|
||||
ORDER BY v2.version_number DESC, v2.id DESC
|
||||
LIMIT 1
|
||||
) lv ON true
|
||||
WHERE b.tenant_id = @tenant_id AND b.id = @bundle_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseControlBundleDetail(
|
||||
Id: reader.GetGuid(0),
|
||||
Slug: reader.GetString(1),
|
||||
Name: reader.GetString(2),
|
||||
Description: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
TotalVersions: reader.GetInt32(7),
|
||||
LatestVersionNumber: reader.IsDBNull(8) ? null : reader.GetInt32(8),
|
||||
LatestVersionId: reader.IsDBNull(9) ? null : reader.GetGuid(9),
|
||||
LatestVersionDigest: reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
LatestPublishedAt: reader.IsDBNull(11) ? null : reader.GetFieldValue<DateTimeOffset>(11),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
CreatedBy: reader.GetString(6));
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var slug = NormalizeSlug(request.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_name_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = Guid.NewGuid();
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundles (
|
||||
id, tenant_id, slug, name, description, created_at, updated_at, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @slug, @name, @description, @created_at, @updated_at, @created_by
|
||||
)
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", bundleId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("slug", slug);
|
||||
command.Parameters.AddWithValue("name", request.Name.Trim());
|
||||
command.Parameters.AddWithValue("description", (object?)NormalizeOptional(request.Description) ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", createdBy);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_exists");
|
||||
}
|
||||
|
||||
var created = await GetBundleAsync(tenantId, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (created is null)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Created release control bundle {BundleId} for tenant {TenantId}", bundleId, tenantGuid);
|
||||
return created;
|
||||
}
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
ORDER BY version_number DESC, id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleVersionSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapVersionSummary(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var versionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id AND id = @version_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
versionCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
versionCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
versionCommand.Parameters.AddWithValue("version_id", versionId);
|
||||
|
||||
await using var versionReader = await versionCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await versionReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = MapVersionSummary(versionReader);
|
||||
await versionReader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
var components = await ReadComponentsAsync(
|
||||
connection,
|
||||
tenantGuid,
|
||||
bundleId,
|
||||
versionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ReleaseControlBundleVersionDetail(
|
||||
Id: summary.Id,
|
||||
BundleId: summary.BundleId,
|
||||
VersionNumber: summary.VersionNumber,
|
||||
Digest: summary.Digest,
|
||||
Status: summary.Status,
|
||||
ComponentsCount: summary.ComponentsCount,
|
||||
Changelog: summary.Changelog,
|
||||
CreatedAt: summary.CreatedAt,
|
||||
PublishedAt: summary.PublishedAt,
|
||||
CreatedBy: summary.CreatedBy,
|
||||
Components: components);
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var nextVersionNumber = await GetNextVersionNumberAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
var digest = ReleaseControlBundleDigest.Compute(bundleId, nextVersionNumber, request.Changelog, normalizedComponents);
|
||||
var versionId = Guid.NewGuid();
|
||||
|
||||
await using (var insertVersionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_versions (
|
||||
id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@version_number,
|
||||
@digest,
|
||||
@status,
|
||||
@components_count,
|
||||
@changelog,
|
||||
@created_at,
|
||||
@published_at,
|
||||
@created_by
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
insertVersionCommand.Parameters.AddWithValue("id", versionId);
|
||||
insertVersionCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertVersionCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertVersionCommand.Parameters.AddWithValue("version_number", nextVersionNumber);
|
||||
insertVersionCommand.Parameters.AddWithValue("digest", digest);
|
||||
insertVersionCommand.Parameters.AddWithValue("status", "published");
|
||||
insertVersionCommand.Parameters.AddWithValue("components_count", normalizedComponents.Count);
|
||||
insertVersionCommand.Parameters.AddWithValue("changelog", (object?)NormalizeOptional(request.Changelog) ?? DBNull.Value);
|
||||
insertVersionCommand.Parameters.AddWithValue("created_at", now);
|
||||
insertVersionCommand.Parameters.AddWithValue("published_at", now);
|
||||
insertVersionCommand.Parameters.AddWithValue("created_by", createdBy);
|
||||
await insertVersionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < normalizedComponents.Count; i++)
|
||||
{
|
||||
var component = normalizedComponents[i];
|
||||
await using var insertComponentCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_components (
|
||||
id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
component_version_id,
|
||||
component_name,
|
||||
image_digest,
|
||||
deploy_order,
|
||||
metadata_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@bundle_version_id,
|
||||
@component_version_id,
|
||||
@component_name,
|
||||
@image_digest,
|
||||
@deploy_order,
|
||||
@metadata_json::jsonb,
|
||||
@created_at
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
insertComponentCommand.Parameters.AddWithValue("id", Guid.NewGuid());
|
||||
insertComponentCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertComponentCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertComponentCommand.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
insertComponentCommand.Parameters.AddWithValue("component_version_id", component.ComponentVersionId);
|
||||
insertComponentCommand.Parameters.AddWithValue("component_name", component.ComponentName);
|
||||
insertComponentCommand.Parameters.AddWithValue("image_digest", component.ImageDigest);
|
||||
insertComponentCommand.Parameters.AddWithValue("deploy_order", component.DeployOrder);
|
||||
insertComponentCommand.Parameters.AddWithValue("metadata_json", component.MetadataJson ?? "{}");
|
||||
insertComponentCommand.Parameters.AddWithValue("created_at", now);
|
||||
await insertComponentCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var version = await GetVersionAsync(tenantId, bundleId, versionId, cancellationToken).ConfigureAwait(false);
|
||||
if (version is null)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Published release control bundle version {VersionId} for bundle {BundleId} tenant {TenantId}",
|
||||
versionId,
|
||||
bundleId,
|
||||
tenantGuid);
|
||||
|
||||
return version;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public async Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var requestedBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedIdempotencyKey = NormalizeIdempotencyKey(request.IdempotencyKey);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var versionExists = await VersionExistsAsync(connection, tenantGuid, bundleId, versionId, cancellationToken).ConfigureAwait(false);
|
||||
if (!versionExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedIdempotencyKey))
|
||||
{
|
||||
var existing = await TryGetMaterializationByIdempotencyKeyAsync(
|
||||
connection,
|
||||
tenantGuid,
|
||||
bundleId,
|
||||
versionId,
|
||||
normalizedIdempotencyKey!,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
await using (var insertCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_materialization_runs (
|
||||
run_id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@run_id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@bundle_version_id,
|
||||
@status,
|
||||
@target_environment,
|
||||
@reason,
|
||||
@requested_by,
|
||||
@idempotency_key,
|
||||
@requested_at,
|
||||
@updated_at
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
insertCommand.Parameters.AddWithValue("run_id", runId);
|
||||
insertCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertCommand.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
insertCommand.Parameters.AddWithValue("status", "queued");
|
||||
insertCommand.Parameters.AddWithValue("target_environment", (object?)NormalizeOptional(request.TargetEnvironment) ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("reason", (object?)NormalizeOptional(request.Reason) ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("requested_by", requestedBy);
|
||||
insertCommand.Parameters.AddWithValue("idempotency_key", (object?)normalizedIdempotencyKey ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("requested_at", now);
|
||||
insertCommand.Parameters.AddWithValue("updated_at", now);
|
||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new ReleaseControlBundleMaterializationRun(
|
||||
RunId: runId,
|
||||
BundleId: bundleId,
|
||||
VersionId: versionId,
|
||||
Status: "queued",
|
||||
TargetEnvironment: NormalizeOptional(request.TargetEnvironment),
|
||||
Reason: NormalizeOptional(request.Reason),
|
||||
RequestedBy: requestedBy,
|
||||
IdempotencyKey: normalizedIdempotencyKey,
|
||||
RequestedAt: now,
|
||||
UpdatedAt: now);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionSummary MapVersionSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ReleaseControlBundleVersionSummary(
|
||||
Id: reader.GetGuid(0),
|
||||
BundleId: reader.GetGuid(1),
|
||||
VersionNumber: reader.GetInt32(2),
|
||||
Digest: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
ComponentsCount: reader.GetInt32(5),
|
||||
Changelog: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
PublishedAt: reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
|
||||
CreatedBy: reader.GetString(9));
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_id_invalid");
|
||||
}
|
||||
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(SetTenantSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.ToString("D"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> BundleExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM release.control_bundles
|
||||
WHERE tenant_id = @tenant_id AND id = @bundle_id
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
var exists = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return exists is not null;
|
||||
}
|
||||
|
||||
private static async Task<bool> VersionExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id AND id = @version_id
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("version_id", versionId);
|
||||
var exists = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return exists is not null;
|
||||
}
|
||||
|
||||
private static async Task<int> GetNextVersionNumberAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return value is int intValue ? intValue : Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ReleaseControlBundleComponent>> ReadComponentsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
component_version_id,
|
||||
component_name,
|
||||
image_digest,
|
||||
deploy_order,
|
||||
metadata_json::text
|
||||
FROM release.control_bundle_components
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @bundle_id
|
||||
AND bundle_version_id = @bundle_version_id
|
||||
ORDER BY deploy_order, component_name, component_version_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
|
||||
var items = new List<ReleaseControlBundleComponent>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new ReleaseControlBundleComponent(
|
||||
ComponentVersionId: reader.GetString(0),
|
||||
ComponentName: reader.GetString(1),
|
||||
ImageDigest: reader.GetString(2),
|
||||
DeployOrder: reader.GetInt32(3),
|
||||
MetadataJson: reader.GetString(4)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static async Task<ReleaseControlBundleMaterializationRun?> TryGetMaterializationByIdempotencyKeyAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
run_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
FROM release.control_bundle_materialization_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @bundle_id
|
||||
AND bundle_version_id = @bundle_version_id
|
||||
AND idempotency_key = @idempotency_key
|
||||
ORDER BY requested_at DESC, run_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
command.Parameters.AddWithValue("idempotency_key", idempotencyKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseControlBundleMaterializationRun(
|
||||
RunId: reader.GetGuid(0),
|
||||
BundleId: reader.GetGuid(1),
|
||||
VersionId: reader.GetGuid(2),
|
||||
Status: reader.GetString(3),
|
||||
TargetEnvironment: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Reason: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
RequestedBy: reader.GetString(6),
|
||||
IdempotencyKey: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
RequestedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
|
||||
}
|
||||
|
||||
private static string NormalizeSlug(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Trim().ToLowerInvariant();
|
||||
var chars = cleaned.Select(static ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray();
|
||||
var compact = new string(chars);
|
||||
while (compact.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
compact = compact.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return compact.Trim('-');
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeIdempotencyKey(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
internal static class ReleaseControlBundleDigest
|
||||
{
|
||||
public static string Compute(
|
||||
Guid bundleId,
|
||||
int versionNumber,
|
||||
string? changelog,
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput> components)
|
||||
{
|
||||
var normalizedComponents = components
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.Select(component =>
|
||||
$"{component.ComponentVersionId}|{component.ComponentName}|{component.ImageDigest}|{component.DeployOrder}|{(component.MetadataJson ?? "{}").Trim()}")
|
||||
.ToArray();
|
||||
|
||||
var payload = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
bundleId.ToString("D"),
|
||||
versionNumber.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
(changelog ?? string.Empty).Trim(),
|
||||
string.Join("\n", normalizedComponents)
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ReleaseControlBundleComponentInput> NormalizeComponents(
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput>? components)
|
||||
{
|
||||
if (components is null || components.Count == 0)
|
||||
{
|
||||
return Array.Empty<ReleaseControlBundleComponentInput>();
|
||||
}
|
||||
|
||||
return components
|
||||
.Select(component => new ReleaseControlBundleComponentInput(
|
||||
component.ComponentVersionId.Trim(),
|
||||
component.ComponentName.Trim(),
|
||||
component.ImageDigest.Trim(),
|
||||
component.DeployOrder,
|
||||
string.IsNullOrWhiteSpace(component.MetadataJson) ? "{}" : component.MetadataJson.Trim()))
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented Pack-21 Administration A1-A7 adapter endpoints under `/api/v1/administration/*` with deterministic migration alias metadata. |
|
||||
| PACK-ADM-02 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented trust owner mutation/read endpoints under `/api/v1/administration/trust-signing/*` with `trust:write`/`trust:admin` policy mapping and DB backing via migration `046_TrustSigningAdministration.sql`. |
|
||||
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
-- Migration: 045_ReleaseControlBundleLifecycle
|
||||
-- Purpose: Add release-control bundle lifecycle persistence for UI v2 shell contracts.
|
||||
-- Sprint: SPRINT_20260219_008 (BE8-03)
|
||||
|
||||
-- ============================================================================
|
||||
-- Bundle catalog
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
CONSTRAINT uq_control_bundles_tenant_slug UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_name
|
||||
ON release.control_bundles (tenant_id, name, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_updated
|
||||
ON release.control_bundles (tenant_id, updated_at DESC, id);
|
||||
|
||||
COMMENT ON TABLE release.control_bundles IS
|
||||
'Release-control bundle identities scoped per tenant.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Immutable bundle versions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL CHECK (version_number > 0),
|
||||
digest TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'deprecated')),
|
||||
components_count INT NOT NULL DEFAULT 0,
|
||||
changelog TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
CONSTRAINT uq_control_bundle_versions_bundle_version UNIQUE (bundle_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_versions_tenant_bundle
|
||||
ON release.control_bundle_versions (tenant_id, bundle_id, version_number DESC, id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_versions_tenant_digest
|
||||
ON release.control_bundle_versions (tenant_id, digest);
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_versions IS
|
||||
'Immutable versions for release-control bundles.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Version components
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_components (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
bundle_version_id UUID NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
|
||||
component_version_id TEXT NOT NULL,
|
||||
component_name TEXT NOT NULL,
|
||||
image_digest TEXT NOT NULL,
|
||||
deploy_order INT NOT NULL DEFAULT 0,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_control_bundle_components_unique
|
||||
UNIQUE (bundle_version_id, component_version_id, deploy_order)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_components_tenant_version
|
||||
ON release.control_bundle_components (tenant_id, bundle_version_id, deploy_order, component_name, component_version_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_components_tenant_bundle
|
||||
ON release.control_bundle_components (tenant_id, bundle_id, bundle_version_id);
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_components IS
|
||||
'Component manifests attached to immutable release-control bundle versions.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Materialization runs
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_materialization_runs (
|
||||
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
bundle_version_id UUID NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
||||
target_environment TEXT,
|
||||
reason TEXT,
|
||||
requested_by TEXT NOT NULL DEFAULT 'system',
|
||||
idempotency_key TEXT,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_materialization_tenant_version
|
||||
ON release.control_bundle_materialization_runs (tenant_id, bundle_id, bundle_version_id, requested_at DESC, run_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_materialization_tenant_status
|
||||
ON release.control_bundle_materialization_runs (tenant_id, status, requested_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_control_bundle_materialization_idempotency
|
||||
ON release.control_bundle_materialization_runs (tenant_id, bundle_id, bundle_version_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_materialization_runs IS
|
||||
'Auditable materialization runs for release-control bundle versions.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Row level security
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE release.control_bundles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_components ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_materialization_runs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundles'
|
||||
AND policyname = 'control_bundles_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundles_tenant_isolation ON release.control_bundles
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_versions'
|
||||
AND policyname = 'control_bundle_versions_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_versions_tenant_isolation ON release.control_bundle_versions
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_components'
|
||||
AND policyname = 'control_bundle_components_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_components_tenant_isolation ON release.control_bundle_components
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_materialization_runs'
|
||||
AND policyname = 'control_bundle_materialization_runs_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_materialization_runs_tenant_isolation ON release.control_bundle_materialization_runs
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Update triggers
|
||||
-- ============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_control_bundles_updated_at ON release.control_bundles;
|
||||
CREATE TRIGGER trg_control_bundles_updated_at
|
||||
BEFORE UPDATE ON release.control_bundles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION release.update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_control_bundle_materialization_runs_updated_at ON release.control_bundle_materialization_runs;
|
||||
CREATE TRIGGER trg_control_bundle_materialization_runs_updated_at
|
||||
BEFORE UPDATE ON release.control_bundle_materialization_runs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION release.update_updated_at_column();
|
||||
@@ -0,0 +1,71 @@
|
||||
-- SPRINT_20260219_016 / PACK-ADM-02
|
||||
-- Administration A6 trust and signing owner mutation persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_keys (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
key_alias text NOT NULL,
|
||||
algorithm text NOT NULL,
|
||||
status text NOT NULL,
|
||||
current_version integer NOT NULL DEFAULT 1,
|
||||
metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_keys_tenant_alias
|
||||
ON release.trust_keys (tenant_id, lower(key_alias));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_keys_tenant_status
|
||||
ON release.trust_keys (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_issuers (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
issuer_name text NOT NULL,
|
||||
issuer_uri text NOT NULL,
|
||||
trust_level text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_issuers_tenant_uri
|
||||
ON release.trust_issuers (tenant_id, lower(issuer_uri));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_issuers_tenant_status
|
||||
ON release.trust_issuers (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_certificates (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
key_id uuid NULL REFERENCES release.trust_keys(id) ON DELETE SET NULL,
|
||||
issuer_id uuid NULL REFERENCES release.trust_issuers(id) ON DELETE SET NULL,
|
||||
serial_number text NOT NULL,
|
||||
status text NOT NULL,
|
||||
not_before timestamptz NOT NULL,
|
||||
not_after timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_certificates_tenant_serial
|
||||
ON release.trust_certificates (tenant_id, lower(serial_number));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_certificates_tenant_status
|
||||
ON release.trust_certificates (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_transparency_configs (
|
||||
tenant_id uuid PRIMARY KEY,
|
||||
log_url text NOT NULL,
|
||||
witness_url text NULL,
|
||||
enforce_inclusion boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public AdministrationTrustSigningMutationEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TrustSigningLifecycle_CreateRotateRevokeAndConfigure_Works()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var createKeyResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
new CreateAdministrationTrustKeyRequest(
|
||||
Alias: "core-signing-k1",
|
||||
Algorithm: "ed25519",
|
||||
MetadataJson: "{\"owner\":\"secops\"}"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createKeyResponse.StatusCode);
|
||||
var key = await createKeyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal("active", key!.Status);
|
||||
Assert.Equal(1, key.CurrentVersion);
|
||||
|
||||
var rotateResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/keys/{key.KeyId}/rotate",
|
||||
new RotateAdministrationTrustKeyRequest("scheduled_rotation", "CHG-100"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, rotateResponse.StatusCode);
|
||||
var rotatedKey = await rotateResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(rotatedKey);
|
||||
Assert.Equal(2, rotatedKey!.CurrentVersion);
|
||||
|
||||
var issuerResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/issuers",
|
||||
new RegisterAdministrationTrustIssuerRequest(
|
||||
Name: "Core Root CA",
|
||||
IssuerUri: "https://issuer.core.example/root",
|
||||
TrustLevel: "high"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, issuerResponse.StatusCode);
|
||||
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuer);
|
||||
|
||||
var certificateResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/certificates",
|
||||
new RegisterAdministrationTrustCertificateRequest(
|
||||
KeyId: key.KeyId,
|
||||
IssuerId: issuer!.IssuerId,
|
||||
SerialNumber: "SER-2026-0001",
|
||||
NotBefore: DateTimeOffset.Parse("2026-02-01T00:00:00Z"),
|
||||
NotAfter: DateTimeOffset.Parse("2027-02-01T00:00:00Z")),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, certificateResponse.StatusCode);
|
||||
var certificate = await certificateResponse.Content.ReadFromJsonAsync<AdministrationTrustCertificateSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(certificate);
|
||||
Assert.Equal("active", certificate!.Status);
|
||||
|
||||
var revokeCertificateResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/certificates/{certificate.CertificateId}/revoke",
|
||||
new RevokeAdministrationTrustCertificateRequest("scheduled_retirement", "IR-77"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, revokeCertificateResponse.StatusCode);
|
||||
var revokedCertificate = await revokeCertificateResponse.Content.ReadFromJsonAsync<AdministrationTrustCertificateSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(revokedCertificate);
|
||||
Assert.Equal("revoked", revokedCertificate!.Status);
|
||||
|
||||
var revokeKeyResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/keys/{key.KeyId}/revoke",
|
||||
new RevokeAdministrationTrustKeyRequest("post-rotation retirement", "CHG-101"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, revokeKeyResponse.StatusCode);
|
||||
var revokedKey = await revokeKeyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(revokedKey);
|
||||
Assert.Equal("revoked", revokedKey!.Status);
|
||||
|
||||
var configureResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/transparency-log",
|
||||
new ConfigureAdministrationTransparencyLogRequest(
|
||||
LogUrl: "https://rekor.core.example",
|
||||
WitnessUrl: "https://rekor-witness.core.example",
|
||||
EnforceInclusion: true),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, configureResponse.StatusCode);
|
||||
var transparencyConfig = await configureResponse.Content.ReadFromJsonAsync<AdministrationTransparencyLogConfig>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(transparencyConfig);
|
||||
Assert.Equal("https://rekor.core.example", transparencyConfig!.LogUrl);
|
||||
Assert.True(transparencyConfig.EnforceInclusion);
|
||||
|
||||
var keys = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustKeySummary>>(
|
||||
"/api/v1/administration/trust-signing/keys?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(keys);
|
||||
Assert.Single(keys!.Items);
|
||||
Assert.Equal("revoked", keys.Items[0].Status);
|
||||
|
||||
var issuers = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustIssuerSummary>>(
|
||||
"/api/v1/administration/trust-signing/issuers?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuers);
|
||||
Assert.Single(issuers!.Items);
|
||||
|
||||
var certificates = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustCertificateSummary>>(
|
||||
"/api/v1/administration/trust-signing/certificates?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(certificates);
|
||||
Assert.Single(certificates!.Items);
|
||||
Assert.Equal("revoked", certificates.Items[0].Status);
|
||||
|
||||
var transparency = await client.GetFromJsonAsync<PlatformItemResponse<AdministrationTransparencyLogConfig>>(
|
||||
"/api/v1/administration/trust-signing/transparency-log",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(transparency);
|
||||
Assert.Equal("https://rekor.core.example", transparency!.Item.LogUrl);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateTrustKey_WithDuplicateAlias_ReturnsConflict()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var request = new CreateAdministrationTrustKeyRequest("duplicate-key", "ed25519", null);
|
||||
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TrustSigningMutations_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TrustSigningMutationEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/rotate", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/certificates/{certificateId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "PUT", PlatformPolicies.TrustAdmin);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, routePattern, StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "trust-signing-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PackAdapterEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public PackAdapterEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DashboardSummary_IsDeterministicAndContainsPackFields()
|
||||
{
|
||||
using var client = CreateTenantClient($"tenant-dashboard-{Guid.NewGuid():N}");
|
||||
|
||||
var firstResponse = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
var secondResponse = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
|
||||
var first = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
var second = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
using var document = JsonDocument.Parse(first);
|
||||
var item = document.RootElement.GetProperty("item");
|
||||
|
||||
Assert.Equal("warning", item.GetProperty("dataConfidence").GetProperty("status").GetString());
|
||||
Assert.Equal(2, item.GetProperty("environmentsWithCriticalReachable").GetInt32());
|
||||
Assert.True(item.GetProperty("topDrivers").GetArrayLength() >= 1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PackAdapterRoutes_ReturnSuccessAndStableOrdering()
|
||||
{
|
||||
using var client = CreateTenantClient($"tenant-ops-{Guid.NewGuid():N}");
|
||||
var endpoints = new[]
|
||||
{
|
||||
"/api/v1/platform/data-integrity/summary",
|
||||
"/api/v1/platform/data-integrity/report",
|
||||
"/api/v1/platform/feeds/freshness",
|
||||
"/api/v1/platform/scan-pipeline/health",
|
||||
"/api/v1/platform/reachability/ingest-health",
|
||||
"/api/v1/administration/summary",
|
||||
"/api/v1/administration/identity-access",
|
||||
"/api/v1/administration/tenant-branding",
|
||||
"/api/v1/administration/notifications",
|
||||
"/api/v1/administration/usage-limits",
|
||||
"/api/v1/administration/policy-governance",
|
||||
"/api/v1/administration/trust-signing",
|
||||
"/api/v1/administration/system",
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
var feedsResponse = await client.GetStringAsync("/api/v1/platform/feeds/freshness", TestContext.Current.CancellationToken);
|
||||
using var feedsDocument = JsonDocument.Parse(feedsResponse);
|
||||
var sources = feedsDocument.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetProperty("source").GetString()!)
|
||||
.ToArray();
|
||||
var ordered = sources.OrderBy(source => source, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(ordered, sources);
|
||||
|
||||
var administrationSummary = await client.GetStringAsync("/api/v1/administration/summary", TestContext.Current.CancellationToken);
|
||||
using var administrationSummaryDocument = JsonDocument.Parse(administrationSummary);
|
||||
var actionPaths = administrationSummaryDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("domains")
|
||||
.EnumerateArray()
|
||||
.Select(domain => domain.GetProperty("actionPath").GetString()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("/administration/identity-access", actionPaths);
|
||||
Assert.Contains("/administration/tenant-branding", actionPaths);
|
||||
|
||||
var identityAccess = await client.GetStringAsync("/api/v1/administration/identity-access", TestContext.Current.CancellationToken);
|
||||
using var identityAccessDocument = JsonDocument.Parse(identityAccess);
|
||||
var tabs = identityAccessDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("tabs")
|
||||
.EnumerateArray()
|
||||
.Select(tab => tab.GetProperty("tabId").GetString()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("users", tabs);
|
||||
Assert.Contains("api-tokens", tabs);
|
||||
|
||||
var policyGovernance = await client.GetStringAsync("/api/v1/administration/policy-governance", TestContext.Current.CancellationToken);
|
||||
using var policyDocument = JsonDocument.Parse(policyGovernance);
|
||||
var aliases = policyDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("legacyAliases")
|
||||
.EnumerateArray()
|
||||
.Select(alias => alias.GetProperty("legacyPath").GetString()!)
|
||||
.ToArray();
|
||||
var orderedAliases = aliases.OrderBy(path => path, StringComparer.Ordinal).ToArray();
|
||||
|
||||
Assert.Equal(orderedAliases, aliases);
|
||||
Assert.Contains("/policy/governance", aliases);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DashboardSummary_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TrustSigningEndpoint_RequiresTrustReadPolicy()
|
||||
{
|
||||
var dataSource = _factory.Services.GetRequiredService<EndpointDataSource>();
|
||||
var trustEndpoint = dataSource.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.Single(endpoint => string.Equals(endpoint.RoutePattern.RawText, "/api/v1/administration/trust-signing", StringComparison.Ordinal));
|
||||
|
||||
var policies = trustEndpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(PlatformPolicies.TrustRead, policies);
|
||||
Assert.DoesNotContain(PlatformPolicies.SetupRead, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "pack-adapter-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseControlEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ReleaseControlEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BundleLifecycle_CreateListPublishAndMaterialize_Works()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest("checkout-service", "Checkout Service", "primary checkout flow"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var list = await client.GetFromJsonAsync<PlatformListResponse<ReleaseControlBundleSummary>>(
|
||||
"/api/v1/release-control/bundles?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(list);
|
||||
Assert.Single(list!.Items);
|
||||
Assert.Equal(created!.Id, list.Items[0].Id);
|
||||
|
||||
var publishRequest = new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "initial release",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: "checkout@1.0.0",
|
||||
ComponentName: "checkout",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions",
|
||||
publishRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
Assert.Equal(1, version!.VersionNumber);
|
||||
Assert.StartsWith("sha256:", version.Digest, StringComparison.Ordinal);
|
||||
Assert.Single(version.Components);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions/{version.Id}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest("prod-eu", "promotion", "idem-001"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, materializeResponse.StatusCode);
|
||||
|
||||
var materialization = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(materialization);
|
||||
Assert.Equal("queued", materialization!.Status);
|
||||
|
||||
var secondMaterializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions/{version.Id}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest("prod-eu", "promotion", "idem-001"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, secondMaterializeResponse.StatusCode);
|
||||
|
||||
var duplicateMaterialization = await secondMaterializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(duplicateMaterialization);
|
||||
Assert.Equal(materialization.RunId, duplicateMaterialization!.RunId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateBundle_WithDuplicateSlug_ReturnsConflict()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var request = new CreateReleaseControlBundleRequest("payments", "Payments", null);
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
first.EnsureSuccessStatusCode();
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListBundles_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "release-control-test");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
|
||||
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
|
||||
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
|
||||
@@ -15,6 +15,7 @@ using StellaOps.Policy.Engine.Events;
|
||||
using StellaOps.Policy.Engine.ExceptionCache;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
@@ -232,6 +233,28 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
return services.AddPolicyDecisionAttestation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the execution evidence policy gate.
|
||||
/// Enforces that artifacts have signed execution evidence before promotion.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExecutionEvidenceGate(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IContextPolicyGate, ExecutionEvidenceGate>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the beacon rate policy gate.
|
||||
/// Enforces minimum beacon verification rate for runtime canary coverage.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBeaconRateGate(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IContextPolicyGate, BeaconRateGate>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds build gate evaluators for exception recheck policies.
|
||||
/// </summary>
|
||||
@@ -328,6 +351,11 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
// Always registered; activation controlled by PolicyEvidenceWeightedScoreOptions.Enabled
|
||||
services.AddEvidenceWeightedScore();
|
||||
|
||||
// Execution evidence and beacon rate gates (Sprint: SPRINT_20260219_013/014)
|
||||
// Always registered; activation controlled by PolicyGateOptions.ExecutionEvidence.Enabled / BeaconRate.Enabled
|
||||
services.AddExecutionEvidenceGate();
|
||||
services.AddBeaconRateGate();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
115
src/Policy/StellaOps.Policy.Engine/Gates/BeaconRateGate.cs
Normal file
115
src/Policy/StellaOps.Policy.Engine/Gates/BeaconRateGate.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces beacon verification rate thresholds.
|
||||
/// When enabled, blocks or warns for releases where beacon coverage is insufficient.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public sealed class BeaconRateGate : IContextPolicyGate
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _options;
|
||||
private readonly ILogger<BeaconRateGate> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public string Id => "beacon-rate";
|
||||
public string DisplayName => "Beacon Verification Rate";
|
||||
public string Description => "Enforces minimum beacon verification rate for runtime canary coverage.";
|
||||
|
||||
public BeaconRateGate(
|
||||
IOptionsMonitor<PolicyGateOptions> options,
|
||||
ILogger<BeaconRateGate> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
var gateOpts = _options.CurrentValue.BeaconRate;
|
||||
|
||||
if (!gateOpts.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Beacon rate gate is disabled"));
|
||||
}
|
||||
|
||||
var environment = context.Environment ?? "unknown";
|
||||
|
||||
// Check if environment requires beacon coverage.
|
||||
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, $"Beacon rate not required for environment '{environment}'"));
|
||||
}
|
||||
|
||||
// Read beacon data from context metadata.
|
||||
double verificationRate = 0;
|
||||
var hasBeaconData = context.Metadata?.TryGetValue("beacon_verification_rate", out var rateStr) == true
|
||||
&& double.TryParse(rateStr, CultureInfo.InvariantCulture, out verificationRate);
|
||||
|
||||
if (!hasBeaconData)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"BeaconRateGate: no beacon data for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return gateOpts.MissingBeaconAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id, "No beacon telemetry data available for this artifact",
|
||||
ImmutableDictionary<string, object>.Empty
|
||||
.Add("environment", environment)
|
||||
.Add("suggestion", "Ensure beacon instrumentation is active in the target environment")))
|
||||
: Task.FromResult(GateResult.Pass(Id, "No beacon data available (warn mode)",
|
||||
new[] { "No beacon telemetry found; ensure runtime canary beacons are deployed" }));
|
||||
}
|
||||
|
||||
// Check minimum beacon count before enforcing rate.
|
||||
int beaconCount = 0;
|
||||
var hasBeaconCount = context.Metadata?.TryGetValue("beacon_verified_count", out var countStr) == true
|
||||
&& int.TryParse(countStr, out beaconCount);
|
||||
|
||||
if (hasBeaconCount && beaconCount < gateOpts.MinBeaconCount)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"BeaconRateGate: insufficient beacon count ({Count} < {Min}) for {Environment}; skipping rate check",
|
||||
beaconCount, gateOpts.MinBeaconCount, environment);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon count ({beaconCount}) below minimum ({gateOpts.MinBeaconCount}); rate enforcement deferred",
|
||||
new[] { $"Only {beaconCount} beacons observed; need {gateOpts.MinBeaconCount} before rate enforcement applies" }));
|
||||
}
|
||||
|
||||
// Evaluate rate against threshold.
|
||||
if (verificationRate < gateOpts.MinVerificationRate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"BeaconRateGate: rate {Rate:F4} below threshold {Threshold:F4} for {Environment}, subject {Subject}",
|
||||
verificationRate, gateOpts.MinVerificationRate, environment, context.SubjectKey);
|
||||
|
||||
var details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("verification_rate", verificationRate)
|
||||
.Add("threshold", gateOpts.MinVerificationRate)
|
||||
.Add("environment", environment);
|
||||
|
||||
return gateOpts.BelowThresholdAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) is below threshold ({gateOpts.MinVerificationRate:P1})",
|
||||
details.Add("suggestion", "Investigate beacon gaps; possible deployment drift or instrumentation failure")))
|
||||
: Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) is below threshold (warn mode)",
|
||||
new[] { $"Beacon rate {verificationRate:P1} < {gateOpts.MinVerificationRate:P1}; investigate potential gaps" }));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"BeaconRateGate: passed for {Environment}, rate={Rate:F4}, subject {Subject}",
|
||||
environment, verificationRate, context.SubjectKey);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) meets threshold ({gateOpts.MinVerificationRate:P1})"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces execution evidence requirements.
|
||||
/// When enabled, blocks or warns for releases without signed execution evidence.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public sealed class ExecutionEvidenceGate : IContextPolicyGate
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _options;
|
||||
private readonly ILogger<ExecutionEvidenceGate> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public string Id => "execution-evidence";
|
||||
public string DisplayName => "Execution Evidence";
|
||||
public string Description => "Requires signed execution evidence (runtime trace attestation) for production releases.";
|
||||
|
||||
public ExecutionEvidenceGate(
|
||||
IOptionsMonitor<PolicyGateOptions> options,
|
||||
ILogger<ExecutionEvidenceGate> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
var gateOpts = _options.CurrentValue.ExecutionEvidence;
|
||||
|
||||
if (!gateOpts.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Execution evidence gate is disabled"));
|
||||
}
|
||||
|
||||
var environment = context.Environment ?? "unknown";
|
||||
|
||||
// Check if environment requires execution evidence.
|
||||
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, $"Execution evidence not required for environment '{environment}'"));
|
||||
}
|
||||
|
||||
// Check metadata for execution evidence fields.
|
||||
var hasEvidence = context.Metadata?.TryGetValue("has_execution_evidence", out var evidenceStr) == true
|
||||
&& string.Equals(evidenceStr, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasEvidence)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ExecutionEvidenceGate: missing execution evidence for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return gateOpts.MissingEvidenceAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id, "Signed execution evidence is required for production releases",
|
||||
ImmutableDictionary<string, object>.Empty
|
||||
.Add("environment", environment)
|
||||
.Add("suggestion", "Run the execution evidence pipeline before promoting to production")))
|
||||
: Task.FromResult(GateResult.Pass(Id, "Execution evidence missing (warn mode)",
|
||||
new[] { "No signed execution evidence found; consider running the trace pipeline" }));
|
||||
}
|
||||
|
||||
// Validate evidence quality if hot symbol count is provided.
|
||||
if (context.Metadata?.TryGetValue("execution_evidence_hot_symbol_count", out var hotStr) == true
|
||||
&& int.TryParse(hotStr, out var hotCount)
|
||||
&& hotCount < gateOpts.MinHotSymbolCount)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ExecutionEvidenceGate: insufficient hot symbols ({Count} < {Min}) for environment {Environment}",
|
||||
hotCount, gateOpts.MinHotSymbolCount, environment);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Execution evidence has insufficient coverage ({hotCount} hot symbols < {gateOpts.MinHotSymbolCount} minimum)",
|
||||
new[] { $"Execution evidence trace has only {hotCount} hot symbols; minimum is {gateOpts.MinHotSymbolCount}" }));
|
||||
}
|
||||
|
||||
// Validate unique call paths if provided.
|
||||
if (context.Metadata?.TryGetValue("execution_evidence_unique_call_paths", out var pathStr) == true
|
||||
&& int.TryParse(pathStr, out var pathCount)
|
||||
&& pathCount < gateOpts.MinUniqueCallPaths)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Execution evidence has insufficient call path coverage ({pathCount} < {gateOpts.MinUniqueCallPaths})",
|
||||
new[] { $"Execution evidence covers only {pathCount} unique call paths; minimum is {gateOpts.MinUniqueCallPaths}" }));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ExecutionEvidenceGate: passed for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id, "Signed execution evidence is present and meets quality thresholds"));
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,34 @@ public sealed record PolicyGateEvidence
|
||||
/// </summary>
|
||||
[JsonPropertyName("pathLength")]
|
||||
public int? PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether signed execution evidence exists for this artifact.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasExecutionEvidence")]
|
||||
public bool HasExecutionEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence predicate digest (sha256:...), if available.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("executionEvidenceDigest")]
|
||||
public string? ExecutionEvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate (0.0 - 1.0), if beacons are observed.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("beaconVerificationRate")]
|
||||
public double? BeaconVerificationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons verified in the lookback window.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("beaconCount")]
|
||||
public int? BeaconCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -366,4 +394,45 @@ public sealed record PolicyGateRequest
|
||||
/// Signature verification method (dsse, cosign, pgp, x509).
|
||||
/// </summary>
|
||||
public string? VexSignatureMethod { get; init; }
|
||||
|
||||
// Execution evidence fields (added for ExecutionEvidenceGate integration)
|
||||
// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
|
||||
/// <summary>
|
||||
/// Whether signed execution evidence exists for this artifact.
|
||||
/// </summary>
|
||||
public bool HasExecutionEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence predicate digest (sha256:...).
|
||||
/// </summary>
|
||||
public string? ExecutionEvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hot symbols in the execution evidence trace summary.
|
||||
/// </summary>
|
||||
public int? ExecutionEvidenceHotSymbolCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unique call paths in the execution evidence.
|
||||
/// </summary>
|
||||
public int? ExecutionEvidenceUniqueCallPaths { get; init; }
|
||||
|
||||
// Beacon attestation fields (added for BeaconRateGate integration)
|
||||
// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate (0.0 - 1.0) for this artifact/environment.
|
||||
/// </summary>
|
||||
public double? BeaconVerificationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons verified in the lookback window.
|
||||
/// </summary>
|
||||
public int? BeaconVerifiedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons expected in the lookback window.
|
||||
/// </summary>
|
||||
public int? BeaconExpectedCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,18 @@ public sealed class PolicyGateOptions
|
||||
/// </summary>
|
||||
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence gate options.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public ExecutionEvidenceGateOptions ExecutionEvidence { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate gate options.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public BeaconRateGateOptions BeaconRate { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether gates are enabled.
|
||||
/// </summary>
|
||||
@@ -216,3 +228,80 @@ public sealed class FacetQuotaOverride
|
||||
/// </summary>
|
||||
public List<string> AllowlistGlobs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the execution evidence gate.
|
||||
/// When enabled, requires signed execution evidence for production releases.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public sealed class ExecutionEvidenceGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether execution evidence enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Action when execution evidence is missing.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType MissingEvidenceAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where execution evidence is required (case-insensitive).
|
||||
/// Default: production only.
|
||||
/// </summary>
|
||||
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of hot symbols to consider evidence meaningful.
|
||||
/// Prevents trivial evidence from satisfying the gate.
|
||||
/// </summary>
|
||||
public int MinHotSymbolCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of unique call paths to consider evidence meaningful.
|
||||
/// </summary>
|
||||
public int MinUniqueCallPaths { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the beacon verification rate gate.
|
||||
/// When enabled, enforces minimum beacon coverage thresholds.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public sealed class BeaconRateGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether beacon rate enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Action when beacon rate is below threshold.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType BelowThresholdAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Action when no beacon data exists for the artifact.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType MissingBeaconAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum beacon verification rate (0.0 - 1.0).
|
||||
/// Beacon rate below this triggers the BelowThresholdAction.
|
||||
/// Default: 0.8 (80% of expected beacons must be observed).
|
||||
/// </summary>
|
||||
public double MinVerificationRate { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where beacon rate is enforced (case-insensitive).
|
||||
/// Default: production only.
|
||||
/// </summary>
|
||||
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of beacons observed before rate enforcement applies.
|
||||
/// Prevents premature gating on insufficient data.
|
||||
/// </summary>
|
||||
public int MinBeaconCount { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory-source policy endpoints (impact and conflict facts).
|
||||
/// </summary>
|
||||
public static class AdvisorySourceEndpoints
|
||||
{
|
||||
private static readonly HashSet<string> AllowedConflictStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"open",
|
||||
"resolved",
|
||||
"dismissed"
|
||||
};
|
||||
|
||||
public static void MapAdvisorySourcePolicyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/advisory-sources")
|
||||
.WithTags("Advisory Sources");
|
||||
|
||||
group.MapGet("/{sourceId}/impact", GetImpactAsync)
|
||||
.WithName("GetAdvisorySourceImpact")
|
||||
.WithDescription("Get policy impact facts for an advisory source.")
|
||||
.Produces<AdvisorySourceImpactResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
||||
|
||||
group.MapGet("/{sourceId}/conflicts", GetConflictsAsync)
|
||||
.WithName("GetAdvisorySourceConflicts")
|
||||
.WithDescription("Get active/resolved advisory conflicts for a source.")
|
||||
.Produces<AdvisorySourceConflictListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetImpactAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] string sourceId,
|
||||
[FromQuery] string? region,
|
||||
[FromQuery] string? environment,
|
||||
[FromQuery] string? sourceFamily,
|
||||
[FromServices] IAdvisorySourcePolicyReadRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(httpContext, out var tenantId))
|
||||
{
|
||||
return TenantMissingProblem();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "sourceId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedSourceId = sourceId.Trim();
|
||||
var normalizedRegion = NormalizeOptional(region);
|
||||
var normalizedEnvironment = NormalizeOptional(environment);
|
||||
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
|
||||
|
||||
var impact = await repository.GetImpactAsync(
|
||||
tenantId,
|
||||
normalizedSourceId,
|
||||
normalizedRegion,
|
||||
normalizedEnvironment,
|
||||
normalizedSourceFamily,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AdvisorySourceImpactResponse
|
||||
{
|
||||
SourceId = normalizedSourceId,
|
||||
SourceFamily = impact.SourceFamily ?? normalizedSourceFamily ?? string.Empty,
|
||||
Region = normalizedRegion,
|
||||
Environment = normalizedEnvironment,
|
||||
ImpactedDecisionsCount = impact.ImpactedDecisionsCount,
|
||||
ImpactSeverity = impact.ImpactSeverity,
|
||||
LastDecisionAt = impact.LastDecisionAt,
|
||||
DecisionRefs = ParseDecisionRefs(impact.DecisionRefsJson),
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConflictsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] string sourceId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] IAdvisorySourcePolicyReadRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(httpContext, out var tenantId))
|
||||
{
|
||||
return TenantMissingProblem();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "sourceId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedStatus = NormalizeOptional(status) ?? "open";
|
||||
if (!AllowedConflictStatuses.Contains(normalizedStatus))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "status must be one of: open, resolved, dismissed.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedSourceId = sourceId.Trim();
|
||||
var normalizedLimit = Math.Clamp(limit ?? 50, 1, 200);
|
||||
var normalizedOffset = Math.Max(offset ?? 0, 0);
|
||||
|
||||
var page = await repository.ListConflictsAsync(
|
||||
tenantId,
|
||||
normalizedSourceId,
|
||||
normalizedStatus,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = page.Items.Select(static item => new AdvisorySourceConflictResponse
|
||||
{
|
||||
ConflictId = item.ConflictId,
|
||||
AdvisoryId = item.AdvisoryId,
|
||||
PairedSourceKey = item.PairedSourceKey,
|
||||
ConflictType = item.ConflictType,
|
||||
Severity = item.Severity,
|
||||
Status = item.Status,
|
||||
Description = item.Description,
|
||||
FirstDetectedAt = item.FirstDetectedAt,
|
||||
LastDetectedAt = item.LastDetectedAt,
|
||||
ResolvedAt = item.ResolvedAt,
|
||||
Details = ParseDetails(item.DetailsJson)
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new AdvisorySourceConflictListResponse
|
||||
{
|
||||
SourceId = normalizedSourceId,
|
||||
Status = normalizedStatus,
|
||||
Limit = normalizedLimit,
|
||||
Offset = normalizedOffset,
|
||||
TotalCount = page.TotalCount,
|
||||
Items = items,
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static bool TryGetTenantId(HttpContext httpContext, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var claimTenant = httpContext.User.Claims.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
tenantId = claimTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var stellaHeaderTenant = httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(stellaHeaderTenant))
|
||||
{
|
||||
tenantId = stellaHeaderTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
tenantId = tenantHeader.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IResult TenantMissingProblem()
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant header required.",
|
||||
detail: "Provide tenant via X-StellaOps-Tenant, X-Tenant-Id, or tenant_id claim.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisorySourceDecisionRef> ParseDecisionRefs(string decisionRefsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(decisionRefsJson))
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(decisionRefsJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
|
||||
var refs = new List<AdvisorySourceDecisionRef>();
|
||||
foreach (var item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
refs.Add(new AdvisorySourceDecisionRef
|
||||
{
|
||||
DecisionId = TryGetString(item, "decisionId") ?? TryGetString(item, "decision_id") ?? string.Empty,
|
||||
DecisionType = TryGetString(item, "decisionType") ?? TryGetString(item, "decision_type"),
|
||||
Label = TryGetString(item, "label"),
|
||||
Route = TryGetString(item, "route")
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? ParseDetails(string detailsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(detailsJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(detailsJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceImpactResponse
|
||||
{
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
public string SourceFamily { get; init; } = string.Empty;
|
||||
public string? Region { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public int ImpactedDecisionsCount { get; init; }
|
||||
public string ImpactSeverity { get; init; } = "none";
|
||||
public DateTimeOffset? LastDecisionAt { get; init; }
|
||||
public IReadOnlyList<AdvisorySourceDecisionRef> DecisionRefs { get; init; } = Array.Empty<AdvisorySourceDecisionRef>();
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceDecisionRef
|
||||
{
|
||||
public string DecisionId { get; init; } = string.Empty;
|
||||
public string? DecisionType { get; init; }
|
||||
public string? Label { get; init; }
|
||||
public string? Route { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceConflictListResponse
|
||||
{
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "open";
|
||||
public int Limit { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
public IReadOnlyList<AdvisorySourceConflictResponse> Items { get; init; } = Array.Empty<AdvisorySourceConflictResponse>();
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceConflictResponse
|
||||
{
|
||||
public Guid ConflictId { get; init; }
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
public string? PairedSourceKey { get; init; }
|
||||
public string ConflictType { get; init; } = string.Empty;
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public DateTimeOffset FirstDetectedAt { get; init; }
|
||||
public DateTimeOffset LastDetectedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public JsonElement? Details { get; init; }
|
||||
}
|
||||
@@ -649,6 +649,9 @@ app.MapExceptionApprovalEndpoints();
|
||||
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
|
||||
app.MapGovernanceEndpoints();
|
||||
|
||||
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
|
||||
app.MapAdvisorySourcePolicyEndpoints();
|
||||
|
||||
// Assistant tool lattice endpoints (Sprint: SPRINT_20260113_005_POLICY_assistant_tool_lattice)
|
||||
app.MapToolLatticeEndpoints();
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ public static class PolicyPersistenceExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
@@ -79,6 +80,7 @@ public static class PolicyPersistenceExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Policy Schema Migration 005: Advisory source impact/conflict projection
|
||||
-- Sprint: SPRINT_20260219_008
|
||||
-- Task: BE8-05
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.advisory_source_impacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_family TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
environment TEXT NOT NULL DEFAULT '',
|
||||
impacted_decisions_count INT NOT NULL DEFAULT 0 CHECK (impacted_decisions_count >= 0),
|
||||
impact_severity TEXT NOT NULL DEFAULT 'none' CHECK (impact_severity IN ('none', 'low', 'medium', 'high', 'critical')),
|
||||
last_decision_at TIMESTAMPTZ,
|
||||
decision_refs JSONB NOT NULL DEFAULT '[]',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_impacts_scope
|
||||
ON policy.advisory_source_impacts (tenant_id, source_key, source_family, region, environment);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_impacts_lookup
|
||||
ON policy.advisory_source_impacts (tenant_id, source_key, impact_severity, updated_at DESC);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_advisory_source_impacts_updated_at ON policy.advisory_source_impacts;
|
||||
CREATE TRIGGER trg_advisory_source_impacts_updated_at
|
||||
BEFORE UPDATE ON policy.advisory_source_impacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.update_updated_at();
|
||||
|
||||
ALTER TABLE policy.advisory_source_impacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY advisory_source_impacts_tenant_isolation ON policy.advisory_source_impacts
|
||||
FOR ALL
|
||||
USING (tenant_id = policy_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.advisory_source_conflicts (
|
||||
conflict_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_family TEXT NOT NULL DEFAULT '',
|
||||
advisory_id TEXT NOT NULL,
|
||||
paired_source_key TEXT,
|
||||
conflict_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
first_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
details_json JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_conflicts_open
|
||||
ON policy.advisory_source_conflicts (tenant_id, source_key, advisory_id, conflict_type, COALESCE(paired_source_key, ''))
|
||||
WHERE status = 'open';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_lookup
|
||||
ON policy.advisory_source_conflicts (tenant_id, source_key, status, severity, last_detected_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_advisory
|
||||
ON policy.advisory_source_conflicts (tenant_id, advisory_id, status);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_advisory_source_conflicts_updated_at ON policy.advisory_source_conflicts;
|
||||
CREATE TRIGGER trg_advisory_source_conflicts_updated_at
|
||||
BEFORE UPDATE ON policy.advisory_source_conflicts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.update_updated_at();
|
||||
|
||||
ALTER TABLE policy.advisory_source_conflicts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY advisory_source_conflicts_tenant_isolation ON policy.advisory_source_conflicts
|
||||
FOR ALL
|
||||
USING (tenant_id = policy_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
-- Best-effort backfill from legacy policy.conflicts rows that encode source scope as source:<key>.
|
||||
INSERT INTO policy.advisory_source_conflicts (
|
||||
tenant_id,
|
||||
source_key,
|
||||
source_family,
|
||||
advisory_id,
|
||||
paired_source_key,
|
||||
conflict_type,
|
||||
severity,
|
||||
status,
|
||||
description,
|
||||
first_detected_at,
|
||||
last_detected_at,
|
||||
details_json,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
c.tenant_id,
|
||||
split_part(c.affected_scope, ':', 2) AS source_key,
|
||||
COALESCE(c.metadata->>'source_family', '') AS source_family,
|
||||
COALESCE(c.metadata->>'advisory_id', 'unknown') AS advisory_id,
|
||||
NULLIF(c.metadata->>'paired_source_key', '') AS paired_source_key,
|
||||
c.conflict_type,
|
||||
c.severity,
|
||||
c.status,
|
||||
c.description,
|
||||
c.created_at AS first_detected_at,
|
||||
COALESCE(c.resolved_at, c.created_at) AS last_detected_at,
|
||||
c.metadata AS details_json,
|
||||
'migration-005-backfill'
|
||||
FROM policy.conflicts c
|
||||
WHERE c.affected_scope LIKE 'source:%'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO policy.advisory_source_impacts (
|
||||
tenant_id,
|
||||
source_key,
|
||||
source_family,
|
||||
impacted_decisions_count,
|
||||
impact_severity,
|
||||
last_decision_at,
|
||||
decision_refs,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
c.tenant_id,
|
||||
c.source_key,
|
||||
c.source_family,
|
||||
COUNT(*)::INT AS impacted_decisions_count,
|
||||
CASE MAX(
|
||||
CASE c.severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END)
|
||||
WHEN 4 THEN 'critical'
|
||||
WHEN 3 THEN 'high'
|
||||
WHEN 2 THEN 'medium'
|
||||
WHEN 1 THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS impact_severity,
|
||||
MAX(c.last_detected_at) AS last_decision_at,
|
||||
'[]'::jsonb AS decision_refs,
|
||||
'migration-005-backfill'
|
||||
FROM policy.advisory_source_conflicts c
|
||||
WHERE c.status = 'open'
|
||||
GROUP BY c.tenant_id, c.source_key, c.source_family
|
||||
ON CONFLICT (tenant_id, source_key, source_family, region, environment) DO UPDATE
|
||||
SET
|
||||
impacted_decisions_count = EXCLUDED.impacted_decisions_count,
|
||||
impact_severity = EXCLUDED.impact_severity,
|
||||
last_decision_at = EXCLUDED.last_decision_at,
|
||||
updated_by = EXCLUDED.updated_by;
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed read model for advisory-source policy facts.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourcePolicyReadRepository
|
||||
: RepositoryBase<PolicyDataSource>, IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
public AdvisorySourcePolicyReadRepository(
|
||||
PolicyDataSource dataSource,
|
||||
ILogger<AdvisorySourcePolicyReadRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH filtered AS (
|
||||
SELECT
|
||||
source_key,
|
||||
source_family,
|
||||
region,
|
||||
environment,
|
||||
impacted_decisions_count,
|
||||
impact_severity,
|
||||
last_decision_at,
|
||||
updated_at,
|
||||
decision_refs
|
||||
FROM policy.advisory_source_impacts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@region IS NULL OR lower(region) = lower(@region))
|
||||
AND (@environment IS NULL OR lower(environment) = lower(@environment))
|
||||
AND (@source_family IS NULL OR lower(source_family) = lower(@source_family))
|
||||
),
|
||||
aggregate_row AS (
|
||||
SELECT
|
||||
@source_key AS source_key,
|
||||
@source_family AS source_family_filter,
|
||||
@region AS region_filter,
|
||||
@environment AS environment_filter,
|
||||
COALESCE(SUM(impacted_decisions_count), 0)::INT AS impacted_decisions_count,
|
||||
COALESCE(MAX(
|
||||
CASE impact_severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS severity_rank,
|
||||
MAX(last_decision_at) AS last_decision_at,
|
||||
MAX(updated_at) AS updated_at,
|
||||
COALESCE(
|
||||
(SELECT decision_refs FROM filtered ORDER BY updated_at DESC NULLS LAST LIMIT 1),
|
||||
'[]'::jsonb
|
||||
)::TEXT AS decision_refs_json
|
||||
FROM filtered
|
||||
)
|
||||
SELECT
|
||||
source_key,
|
||||
source_family_filter,
|
||||
region_filter,
|
||||
environment_filter,
|
||||
impacted_decisions_count,
|
||||
CASE severity_rank
|
||||
WHEN 4 THEN 'critical'
|
||||
WHEN 3 THEN 'high'
|
||||
WHEN 2 THEN 'medium'
|
||||
WHEN 1 THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS impact_severity,
|
||||
last_decision_at,
|
||||
updated_at,
|
||||
decision_refs_json
|
||||
FROM aggregate_row;
|
||||
""";
|
||||
|
||||
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
|
||||
var normalizedRegion = NormalizeOptional(region);
|
||||
var normalizedEnvironment = NormalizeOptional(environment);
|
||||
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "region", normalizedRegion);
|
||||
AddParameter(command, "environment", normalizedEnvironment);
|
||||
AddParameter(command, "source_family", normalizedSourceFamily);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisorySourceImpactSnapshot(
|
||||
SourceKey: reader.GetString(0),
|
||||
SourceFamily: GetNullableString(reader, 1),
|
||||
Region: GetNullableString(reader, 2),
|
||||
Environment: GetNullableString(reader, 3),
|
||||
ImpactedDecisionsCount: reader.GetInt32(4),
|
||||
ImpactSeverity: reader.GetString(5),
|
||||
LastDecisionAt: GetNullableDateTimeOffset(reader, 6),
|
||||
UpdatedAt: GetNullableDateTimeOffset(reader, 7),
|
||||
DecisionRefsJson: reader.GetString(8));
|
||||
}
|
||||
|
||||
public async Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string countSql = """
|
||||
SELECT COUNT(*)::INT
|
||||
FROM policy.advisory_source_conflicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@status IS NULL OR status = @status)
|
||||
""";
|
||||
|
||||
const string listSql = """
|
||||
SELECT
|
||||
conflict_id,
|
||||
advisory_id,
|
||||
paired_source_key,
|
||||
conflict_type,
|
||||
severity,
|
||||
status,
|
||||
description,
|
||||
first_detected_at,
|
||||
last_detected_at,
|
||||
resolved_at,
|
||||
details_json::TEXT
|
||||
FROM policy.advisory_source_conflicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@status IS NULL OR status = @status)
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END DESC,
|
||||
last_detected_at DESC,
|
||||
conflict_id
|
||||
LIMIT @limit
|
||||
OFFSET @offset
|
||||
""";
|
||||
|
||||
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
|
||||
var normalizedStatus = NormalizeOptional(status);
|
||||
var normalizedLimit = Math.Clamp(limit, 1, 200);
|
||||
var normalizedOffset = Math.Max(offset, 0);
|
||||
|
||||
var totalCount = await ExecuteScalarAsync<int>(
|
||||
tenantId,
|
||||
countSql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "status", normalizedStatus);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = await QueryAsync(
|
||||
tenantId,
|
||||
listSql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "status", normalizedStatus);
|
||||
AddParameter(command, "limit", normalizedLimit);
|
||||
AddParameter(command, "offset", normalizedOffset);
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisorySourceConflictPage(items, totalCount);
|
||||
}
|
||||
|
||||
private static AdvisorySourceConflictRecord MapConflict(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdvisorySourceConflictRecord(
|
||||
ConflictId: reader.GetGuid(0),
|
||||
AdvisoryId: reader.GetString(1),
|
||||
PairedSourceKey: GetNullableString(reader, 2),
|
||||
ConflictType: reader.GetString(3),
|
||||
Severity: reader.GetString(4),
|
||||
Status: reader.GetString(5),
|
||||
Description: reader.GetString(6),
|
||||
FirstDetectedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
LastDetectedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
ResolvedAt: GetNullableDateTimeOffset(reader, 9),
|
||||
DetailsJson: reader.GetString(10));
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{parameterName} is required.", parameterName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Read-model access for Advisory Sources policy-owned impact and conflict facts.
|
||||
/// </summary>
|
||||
public interface IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceImpactSnapshot(
|
||||
string SourceKey,
|
||||
string? SourceFamily,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int ImpactedDecisionsCount,
|
||||
string ImpactSeverity,
|
||||
DateTimeOffset? LastDecisionAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
string DecisionRefsJson);
|
||||
|
||||
public sealed record AdvisorySourceConflictRecord(
|
||||
Guid ConflictId,
|
||||
string AdvisoryId,
|
||||
string? PairedSourceKey,
|
||||
string ConflictType,
|
||||
string Severity,
|
||||
string Status,
|
||||
string Description,
|
||||
DateTimeOffset FirstDetectedAt,
|
||||
DateTimeOffset LastDetectedAt,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string DetailsJson);
|
||||
|
||||
public sealed record AdvisorySourceConflictPage(
|
||||
IReadOnlyList<AdvisorySourceConflictRecord> Items,
|
||||
int TotalCount);
|
||||
@@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
@@ -81,6 +82,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for BeaconRateGate.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.014")]
|
||||
public sealed class BeaconRateGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 14, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing beacon data
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate threshold enforcement
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateAboveThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.95, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("below threshold", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Minimum beacon count
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BelowMinBeaconCount_SkipsRateEnforcement()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, minBeaconCount: 50);
|
||||
// Rate is bad but count is too low to enforce.
|
||||
var context = CreateContext("production", beaconRate: 0.3, beaconCount: 5);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("deferred", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary conditions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExactlyAtThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.8, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_JustBelowThreshold_TriggersAction()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.7999, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private BeaconRateGate CreateGate(
|
||||
bool enabled = true,
|
||||
double minRate = 0.8,
|
||||
int minBeaconCount = 10,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
PolicyGateDecisionType belowAction = PolicyGateDecisionType.Warn)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
BeaconRate = new BeaconRateGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MinVerificationRate = minRate,
|
||||
MinBeaconCount = minBeaconCount,
|
||||
MissingBeaconAction = missingAction,
|
||||
BelowThresholdAction = belowAction,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new BeaconRateGate(monitor, NullLogger<BeaconRateGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
double? beaconRate = null,
|
||||
int? beaconCount = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (beaconRate.HasValue)
|
||||
{
|
||||
metadata["beacon_verification_rate"] = beaconRate.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
if (beaconCount.HasValue)
|
||||
{
|
||||
metadata["beacon_verified_count"] = beaconCount.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ExecutionEvidenceGate.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.013")]
|
||||
public sealed class ExecutionEvidenceGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiredEnvironment_EnforcesEvidence()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence present
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EvidencePresent_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("production", hasEvidence: true);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("present", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing evidence actions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Quality checks
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InsufficientHotSymbols_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 10);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 2);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("insufficient", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SufficientHotSymbols_ReturnsCleanPass()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 3);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 15);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ExecutionEvidenceGate CreateGate(
|
||||
bool enabled = true,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
int minHotSymbols = 3,
|
||||
int minCallPaths = 1)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
ExecutionEvidence = new ExecutionEvidenceGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MissingEvidenceAction = missingAction,
|
||||
MinHotSymbolCount = minHotSymbols,
|
||||
MinUniqueCallPaths = minCallPaths,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new ExecutionEvidenceGate(monitor, NullLogger<ExecutionEvidenceGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
bool hasEvidence,
|
||||
int? hotSymbolCount = null,
|
||||
int? uniqueCallPaths = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (hasEvidence)
|
||||
{
|
||||
metadata["has_execution_evidence"] = "true";
|
||||
}
|
||||
if (hotSymbolCount.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_hot_symbol_count"] = hotSymbolCount.Value.ToString();
|
||||
}
|
||||
if (uniqueCallPaths.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_unique_call_paths"] = uniqueCallPaths.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class AdvisorySourceEndpointsTests : IClassFixture<TestPolicyGatewayFactory>
|
||||
{
|
||||
private readonly TestPolicyGatewayFactory _factory;
|
||||
|
||||
public AdvisorySourceEndpointsTests(TestPolicyGatewayFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetImpact_ReturnsPolicyImpactPayload()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/advisory-sources/nvd/impact?region=us-east&environment=prod&sourceFamily=nvd",
|
||||
CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceImpactResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.SourceId.Should().Be("nvd");
|
||||
payload.ImpactedDecisionsCount.Should().Be(4);
|
||||
payload.ImpactSeverity.Should().Be("high");
|
||||
payload.DecisionRefs.Should().ContainSingle();
|
||||
payload.DecisionRefs[0].DecisionId.Should().Be("APR-2201");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetImpact_WithoutTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/impact", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_DefaultStatus_ReturnsOpenConflicts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.SourceId.Should().Be("nvd");
|
||||
payload.Status.Should().Be("open");
|
||||
payload.TotalCount.Should().Be(1);
|
||||
payload.Items.Should().ContainSingle();
|
||||
payload.Items[0].AdvisoryId.Should().Be("CVE-2026-1188");
|
||||
payload.Items[0].Status.Should().Be("open");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_WithResolvedStatus_ReturnsResolvedConflicts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=resolved", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TotalCount.Should().Be(1);
|
||||
payload.Items.Should().ContainSingle();
|
||||
payload.Items[0].Status.Should().Be("resolved");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_WithInvalidStatus_ReturnsBadRequest()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=invalid", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
using GatewayProgram = StellaOps.Policy.Gateway.Program;
|
||||
|
||||
@@ -82,6 +83,8 @@ public sealed class TestPolicyGatewayFactory : WebApplicationFactory<GatewayProg
|
||||
services.AddSingleton<IAuditableExceptionRepository, InMemoryExceptionRepository>();
|
||||
services.RemoveAll<IGateDecisionHistoryRepository>();
|
||||
services.AddSingleton<IGateDecisionHistoryRepository, InMemoryGateDecisionHistoryRepository>();
|
||||
services.RemoveAll<IAdvisorySourcePolicyReadRepository>();
|
||||
services.AddSingleton<IAdvisorySourcePolicyReadRepository, InMemoryAdvisorySourcePolicyReadRepository>();
|
||||
|
||||
// Override JWT bearer auth to accept test tokens without real OIDC discovery
|
||||
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
@@ -330,3 +333,124 @@ internal sealed class InMemoryGateDecisionHistoryRepository : IGateDecisionHisto
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of advisory source policy read models for endpoint tests.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryAdvisorySourcePolicyReadRepository : IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
private readonly AdvisorySourceImpactSnapshot _impact = new(
|
||||
SourceKey: "nvd",
|
||||
SourceFamily: "nvd",
|
||||
Region: "us-east",
|
||||
Environment: "prod",
|
||||
ImpactedDecisionsCount: 4,
|
||||
ImpactSeverity: "high",
|
||||
LastDecisionAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
|
||||
DecisionRefsJson: """
|
||||
[
|
||||
{
|
||||
"decisionId": "APR-2201",
|
||||
"decisionType": "approval",
|
||||
"label": "Approval APR-2201",
|
||||
"route": "/release-control/approvals/apr-2201"
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
private readonly List<AdvisorySourceConflictRecord> _conflicts =
|
||||
[
|
||||
new(
|
||||
ConflictId: Guid.Parse("49b08f4c-474e-4a88-9f71-b7f74572f9d3"),
|
||||
AdvisoryId: "CVE-2026-1188",
|
||||
PairedSourceKey: "ghsa",
|
||||
ConflictType: "severity_mismatch",
|
||||
Severity: "high",
|
||||
Status: "open",
|
||||
Description: "Severity mismatch between NVD and GHSA.",
|
||||
FirstDetectedAt: DateTimeOffset.Parse("2026-02-19T07:40:00Z"),
|
||||
LastDetectedAt: DateTimeOffset.Parse("2026-02-19T08:05:00Z"),
|
||||
ResolvedAt: null,
|
||||
DetailsJson: """{"lhs":"high","rhs":"critical"}"""),
|
||||
new(
|
||||
ConflictId: Guid.Parse("cb605891-90d5-4081-a17c-e55327ffce34"),
|
||||
AdvisoryId: "CVE-2026-2001",
|
||||
PairedSourceKey: "osv",
|
||||
ConflictType: "remediation_mismatch",
|
||||
Severity: "medium",
|
||||
Status: "resolved",
|
||||
Description: "Remediation mismatch resolved after triage.",
|
||||
FirstDetectedAt: DateTimeOffset.Parse("2026-02-18T11:00:00Z"),
|
||||
LastDetectedAt: DateTimeOffset.Parse("2026-02-18T13:15:00Z"),
|
||||
ResolvedAt: DateTimeOffset.Parse("2026-02-18T14:00:00Z"),
|
||||
DetailsJson: JsonSerializer.Serialize(new { resolution = "accepted_nvd", actor = "security-bot" }))
|
||||
];
|
||||
|
||||
public Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new AdvisorySourceImpactSnapshot(
|
||||
SourceKey: sourceKey,
|
||||
SourceFamily: sourceFamily,
|
||||
Region: region,
|
||||
Environment: environment,
|
||||
ImpactedDecisionsCount: 0,
|
||||
ImpactSeverity: "none",
|
||||
LastDecisionAt: null,
|
||||
UpdatedAt: null,
|
||||
DecisionRefsJson: "[]"));
|
||||
}
|
||||
|
||||
return Task.FromResult(_impact with
|
||||
{
|
||||
Region = region ?? _impact.Region,
|
||||
Environment = environment ?? _impact.Environment,
|
||||
SourceFamily = sourceFamily ?? _impact.SourceFamily
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new AdvisorySourceConflictPage(Array.Empty<AdvisorySourceConflictRecord>(), 0));
|
||||
}
|
||||
|
||||
var filtered = _conflicts
|
||||
.Where(item => string.IsNullOrWhiteSpace(status) || string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(static item => item.Severity switch
|
||||
{
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" => 1,
|
||||
_ => 0
|
||||
})
|
||||
.ThenByDescending(static item => item.LastDetectedAt)
|
||||
.ThenBy(static item => item.ConflictId)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new AdvisorySourceConflictPage(page, filtered.Count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IContributorTrustScorer
|
||||
{
|
||||
double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions);
|
||||
string GetTrustTier(double score);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IRemediationMatcher
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IRemediationRegistry
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> ListTemplatesAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<FixTemplate?> GetTemplateAsync(Guid id, CancellationToken ct = default);
|
||||
Task<FixTemplate> CreateTemplateAsync(FixTemplate template, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PrSubmission>> ListSubmissionsAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<PrSubmission?> GetSubmissionAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PrSubmission> CreateSubmissionAsync(PrSubmission submission, CancellationToken ct = default);
|
||||
Task UpdateSubmissionStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record Contributor
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string? DisplayName { get; init; }
|
||||
public int VerifiedFixes { get; init; }
|
||||
public int TotalSubmissions { get; init; }
|
||||
public int RejectedSubmissions { get; init; }
|
||||
public double TrustScore { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastActiveAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record FixTemplate
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
public string VersionRange { get; init; } = string.Empty;
|
||||
public string PatchContent { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public Guid? ContributorId { get; init; }
|
||||
public Guid? SourceId { get; init; }
|
||||
public string Status { get; init; } = "pending";
|
||||
public double TrustScore { get; init; }
|
||||
public string? DsseDigest { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record MarketplaceSource
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Url { get; init; }
|
||||
public string SourceType { get; init; } = "community";
|
||||
public bool Enabled { get; init; } = true;
|
||||
public double TrustScore { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastSyncAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record PrSubmission
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid? FixTemplateId { get; init; }
|
||||
public string PrUrl { get; init; } = string.Empty;
|
||||
public string RepositoryUrl { get; init; } = string.Empty;
|
||||
public string SourceBranch { get; init; } = string.Empty;
|
||||
public string TargetBranch { get; init; } = string.Empty;
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "opened";
|
||||
public string? PreScanDigest { get; init; }
|
||||
public string? PostScanDigest { get; init; }
|
||||
public string? ReachabilityDeltaDigest { get; init; }
|
||||
public string? FixChainDsseDigest { get; init; }
|
||||
public string? Verdict { get; init; }
|
||||
public Guid? ContributorId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? MergedAt { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public sealed class ContributorTrustScorer : IContributorTrustScorer
|
||||
{
|
||||
public double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions)
|
||||
{
|
||||
var denominator = Math.Max(totalSubmissions, 1);
|
||||
var raw = (verifiedFixes * 1.0 - rejectedSubmissions * 0.5) / denominator;
|
||||
return Math.Clamp(raw, 0.0, 1.0);
|
||||
}
|
||||
|
||||
public string GetTrustTier(double score) => score switch
|
||||
{
|
||||
> 0.8 => "trusted",
|
||||
> 0.5 => "established",
|
||||
> 0.2 => "new",
|
||||
_ => "untrusted"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public interface IRemediationVerifier
|
||||
{
|
||||
Task<VerificationResult> VerifyAsync(PrSubmission submission, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record VerificationResult(
|
||||
string Verdict,
|
||||
string? ReachabilityDeltaDigest,
|
||||
string? FixChainDsseDigest,
|
||||
IReadOnlyList<string> AffectedPaths,
|
||||
DateTimeOffset VerifiedAt);
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public sealed class RemediationVerifier : IRemediationVerifier
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RemediationVerifier(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<VerificationResult> VerifyAsync(PrSubmission submission, CancellationToken ct = default)
|
||||
{
|
||||
// Stub: real implementation will integrate with scan service and reachability delta
|
||||
var verdict = DetermineVerdict(submission);
|
||||
var result = new VerificationResult(
|
||||
Verdict: verdict,
|
||||
ReachabilityDeltaDigest: submission.ReachabilityDeltaDigest,
|
||||
FixChainDsseDigest: submission.FixChainDsseDigest,
|
||||
AffectedPaths: Array.Empty<string>(),
|
||||
VerifiedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string DetermineVerdict(PrSubmission submission)
|
||||
{
|
||||
if (string.IsNullOrEmpty(submission.PreScanDigest) || string.IsNullOrEmpty(submission.PostScanDigest))
|
||||
{
|
||||
return "inconclusive";
|
||||
}
|
||||
|
||||
if (submission.PreScanDigest == submission.PostScanDigest)
|
||||
{
|
||||
return "not_fixed";
|
||||
}
|
||||
|
||||
return "fixed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
CREATE SCHEMA IF NOT EXISTS remediation;
|
||||
|
||||
CREATE TABLE remediation.fix_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cve_id TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
version_range TEXT NOT NULL,
|
||||
patch_content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
contributor_id UUID,
|
||||
source_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
dsse_digest TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.pr_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
fix_template_id UUID REFERENCES remediation.fix_templates(id),
|
||||
pr_url TEXT NOT NULL,
|
||||
repository_url TEXT NOT NULL,
|
||||
source_branch TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
cve_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'opened',
|
||||
pre_scan_digest TEXT,
|
||||
post_scan_digest TEXT,
|
||||
reachability_delta_digest TEXT,
|
||||
fix_chain_dsse_digest TEXT,
|
||||
verdict TEXT,
|
||||
contributor_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
merged_at TIMESTAMPTZ,
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.contributors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
verified_fixes INT NOT NULL DEFAULT 0,
|
||||
total_submissions INT NOT NULL DEFAULT 0,
|
||||
rejected_submissions INT NOT NULL DEFAULT 0,
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_active_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.marketplace_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
source_type TEXT NOT NULL DEFAULT 'community',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_sync_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fix_templates_cve ON remediation.fix_templates(cve_id);
|
||||
CREATE INDEX idx_fix_templates_purl ON remediation.fix_templates(purl);
|
||||
CREATE INDEX idx_pr_submissions_cve ON remediation.pr_submissions(cve_id);
|
||||
CREATE INDEX idx_pr_submissions_status ON remediation.pr_submissions(status);
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public interface IFixTemplateRepository
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> ListAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user