Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,7 @@
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -156,6 +156,7 @@ app.MapDeadLetterEndpoints();
app.MapReleaseEndpoints();
app.MapApprovalEndpoints();
app.MapReleaseDashboardEndpoints();
app.MapReleaseControlV2Endpoints();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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})"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Remediation.Core.Abstractions;
public interface IContributorTrustScorer
{
double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions);
string GetTrustTier(double score);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

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

View File

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