Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -30,4 +30,17 @@ public static class PlatformPolicies
|
||||
public const string PolicyRead = "platform.policy.read";
|
||||
public const string PolicyWrite = "platform.policy.write";
|
||||
public const string PolicyEvaluate = "platform.policy.evaluate";
|
||||
|
||||
// Release control bundle lifecycle policies (SPRINT_20260219_008 / BE8-02)
|
||||
public const string ReleaseControlRead = "platform.releasecontrol.read";
|
||||
public const string ReleaseControlOperate = "platform.releasecontrol.operate";
|
||||
|
||||
// Federated telemetry policies (SPRINT_20260220_007)
|
||||
public const string FederationRead = "platform.federation.read";
|
||||
public const string FederationManage = "platform.federation.manage";
|
||||
|
||||
// Trust ownership transition policies (Pack-21 follow-on auth hardening)
|
||||
public const string TrustRead = "platform.trust.read";
|
||||
public const string TrustWrite = "platform.trust.write";
|
||||
public const string TrustAdmin = "platform.trust.admin";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Constants;
|
||||
|
||||
public static class PlatformScopes
|
||||
@@ -30,4 +32,17 @@ public static class PlatformScopes
|
||||
public const string PolicyRead = "policy.read";
|
||||
public const string PolicyWrite = "policy.write";
|
||||
public const string PolicyEvaluate = "policy.evaluate";
|
||||
|
||||
// Release control bundle lifecycle scopes (SPRINT_20260219_008 / BE8-02)
|
||||
public const string OrchRead = "orch:read";
|
||||
public const string OrchOperate = "orch:operate";
|
||||
|
||||
// Federated telemetry scopes (SPRINT_20260220_007)
|
||||
public const string FederationRead = "platform:federation:read";
|
||||
public const string FederationManage = "platform:federation:manage";
|
||||
|
||||
// Trust ownership transition scopes (Pack-21 follow-on auth hardening)
|
||||
public const string TrustRead = StellaOpsScopes.TrustRead;
|
||||
public const string TrustWrite = StellaOpsScopes.TrustWrite;
|
||||
public const string TrustAdmin = StellaOpsScopes.TrustAdmin;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record AdministrationTrustKeySummary(
|
||||
Guid KeyId,
|
||||
string Alias,
|
||||
string Algorithm,
|
||||
string Status,
|
||||
int CurrentVersion,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTrustIssuerSummary(
|
||||
Guid IssuerId,
|
||||
string Name,
|
||||
string IssuerUri,
|
||||
string TrustLevel,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTrustCertificateSummary(
|
||||
Guid CertificateId,
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
string SerialNumber,
|
||||
string Status,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record AdministrationTransparencyLogConfig(
|
||||
string LogUrl,
|
||||
string? WitnessUrl,
|
||||
bool EnforceInclusion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record CreateAdministrationTrustKeyRequest(
|
||||
string Alias,
|
||||
string Algorithm,
|
||||
string? MetadataJson);
|
||||
|
||||
public sealed record RotateAdministrationTrustKeyRequest(
|
||||
string? Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RevokeAdministrationTrustKeyRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RegisterAdministrationTrustIssuerRequest(
|
||||
string Name,
|
||||
string IssuerUri,
|
||||
string TrustLevel);
|
||||
|
||||
public sealed record RegisterAdministrationTrustCertificateRequest(
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
string SerialNumber,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter);
|
||||
|
||||
public sealed record RevokeAdministrationTrustCertificateRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record ConfigureAdministrationTransparencyLogRequest(
|
||||
string LogUrl,
|
||||
string? WitnessUrl,
|
||||
bool EnforceInclusion);
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record FederationConsentStateResponse(
|
||||
bool Granted,
|
||||
string? GrantedBy,
|
||||
DateTimeOffset? GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? DsseDigest);
|
||||
|
||||
public sealed record FederationGrantConsentRequest(
|
||||
string GrantedBy,
|
||||
int? TtlHours);
|
||||
|
||||
public sealed record FederationConsentProofResponse(
|
||||
string TenantId,
|
||||
string GrantedBy,
|
||||
DateTimeOffset GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string DsseDigest);
|
||||
|
||||
public sealed record FederationRevokeConsentRequest(
|
||||
string RevokedBy);
|
||||
|
||||
public sealed record FederationStatusResponse(
|
||||
bool Enabled,
|
||||
bool SealedMode,
|
||||
string SiteId,
|
||||
bool ConsentGranted,
|
||||
double EpsilonRemaining,
|
||||
double EpsilonTotal,
|
||||
bool BudgetExhausted,
|
||||
DateTimeOffset NextBudgetReset,
|
||||
int BundleCount);
|
||||
|
||||
public sealed record FederationBundleSummary(
|
||||
Guid Id,
|
||||
string SourceSiteId,
|
||||
int BucketCount,
|
||||
int SuppressedBuckets,
|
||||
double EpsilonSpent,
|
||||
bool Verified,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record FederationBundleDetailResponse(
|
||||
Guid Id,
|
||||
string SourceSiteId,
|
||||
int TotalFacts,
|
||||
int BucketCount,
|
||||
int SuppressedBuckets,
|
||||
double EpsilonSpent,
|
||||
string ConsentDsseDigest,
|
||||
string BundleDsseDigest,
|
||||
bool Verified,
|
||||
DateTimeOffset AggregatedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<FederationBucketDetail> Buckets);
|
||||
|
||||
public sealed record FederationBucketDetail(
|
||||
string CveId,
|
||||
int ObservationCount,
|
||||
int ArtifactCount,
|
||||
double NoisyCount,
|
||||
bool Suppressed);
|
||||
|
||||
public sealed record FederationIntelligenceResponse(
|
||||
IReadOnlyList<FederationIntelligenceEntry> Entries,
|
||||
int TotalEntries,
|
||||
int UniqueCves,
|
||||
int ContributingSites,
|
||||
DateTimeOffset LastUpdated);
|
||||
|
||||
public sealed record FederationIntelligenceEntry(
|
||||
string CveId,
|
||||
string SourceSiteId,
|
||||
int ObservationCount,
|
||||
double NoisyCount,
|
||||
int ArtifactCount,
|
||||
DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record FederationPrivacyBudgetResponse(
|
||||
double Remaining,
|
||||
double Total,
|
||||
bool Exhausted,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset NextReset,
|
||||
int QueriesThisPeriod,
|
||||
int SuppressedThisPeriod);
|
||||
|
||||
public sealed record FederationTriggerResponse(
|
||||
bool Triggered,
|
||||
string? Reason);
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record ReleaseControlBundleSummary(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description,
|
||||
int TotalVersions,
|
||||
int? LatestVersionNumber,
|
||||
Guid? LatestVersionId,
|
||||
string? LatestVersionDigest,
|
||||
DateTimeOffset? LatestPublishedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record ReleaseControlBundleDetail(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description,
|
||||
int TotalVersions,
|
||||
int? LatestVersionNumber,
|
||||
Guid? LatestVersionId,
|
||||
string? LatestVersionDigest,
|
||||
DateTimeOffset? LatestPublishedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CreatedBy);
|
||||
|
||||
public sealed record ReleaseControlBundleVersionSummary(
|
||||
Guid Id,
|
||||
Guid BundleId,
|
||||
int VersionNumber,
|
||||
string Digest,
|
||||
string Status,
|
||||
int ComponentsCount,
|
||||
string? Changelog,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string CreatedBy);
|
||||
|
||||
public sealed record ReleaseControlBundleComponent(
|
||||
string ComponentVersionId,
|
||||
string ComponentName,
|
||||
string ImageDigest,
|
||||
int DeployOrder,
|
||||
string MetadataJson);
|
||||
|
||||
public sealed record ReleaseControlBundleVersionDetail(
|
||||
Guid Id,
|
||||
Guid BundleId,
|
||||
int VersionNumber,
|
||||
string Digest,
|
||||
string Status,
|
||||
int ComponentsCount,
|
||||
string? Changelog,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string CreatedBy,
|
||||
IReadOnlyList<ReleaseControlBundleComponent> Components);
|
||||
|
||||
public sealed record ReleaseControlBundleMaterializationRun(
|
||||
Guid RunId,
|
||||
Guid BundleId,
|
||||
Guid VersionId,
|
||||
string Status,
|
||||
string? TargetEnvironment,
|
||||
string? Reason,
|
||||
string RequestedBy,
|
||||
string? IdempotencyKey,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record CreateReleaseControlBundleRequest(
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description);
|
||||
|
||||
public sealed record PublishReleaseControlBundleVersionRequest(
|
||||
string? Changelog,
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput>? Components);
|
||||
|
||||
public sealed record ReleaseControlBundleComponentInput(
|
||||
string ComponentVersionId,
|
||||
string ComponentName,
|
||||
string ImageDigest,
|
||||
int DeployOrder,
|
||||
string? MetadataJson);
|
||||
|
||||
public sealed record MaterializeReleaseControlBundleVersionRequest(
|
||||
string? TargetEnvironment,
|
||||
string? Reason,
|
||||
string? IdempotencyKey);
|
||||
@@ -0,0 +1,452 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Trust and signing owner mutation endpoints backing Administration A6.
|
||||
/// </summary>
|
||||
public static class AdministrationTrustSigningMutationEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapAdministrationTrustSigningMutationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/administration/trust-signing")
|
||||
.WithTags("Administration");
|
||||
|
||||
group.MapGet("/keys", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListKeysAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustKeySummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustKeys")
|
||||
.WithSummary("List trust signing keys")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/keys", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.CreateKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/keys/{created.KeyId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("CreateAdministrationTrustKey")
|
||||
.WithSummary("Create trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/keys/{keyId:guid}/rotate", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RotateKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
keyId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RotateAdministrationTrustKey")
|
||||
.WithSummary("Rotate trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/keys/{keyId:guid}/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RevokeKeyAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
keyId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RevokeAdministrationTrustKey")
|
||||
.WithSummary("Revoke trust signing key")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/issuers", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListIssuersAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustIssuerSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustIssuers")
|
||||
.WithSummary("List trust issuers")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/issuers", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.RegisterIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/issuers/{created.IssuerId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RegisterAdministrationTrustIssuer")
|
||||
.WithSummary("Register trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapGet("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await store.ListCertificatesAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<AdministrationTrustCertificateSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListAdministrationTrustCertificates")
|
||||
.WithSummary("List trust certificates")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPost("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.RegisterCertificateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/administration/trust-signing/certificates/{created.CertificateId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("RegisterAdministrationTrustCertificate")
|
||||
.WithSummary("Register trust certificate")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/certificates/{certificateId:guid}/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.RevokeCertificateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
certificateId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId);
|
||||
}
|
||||
})
|
||||
.WithName("RevokeAdministrationTrustCertificate")
|
||||
.WithSummary("Revoke trust certificate")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/transparency-log", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var config = await store.GetTransparencyLogConfigAsync(
|
||||
requestContext!.TenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "transparency_log_not_configured" });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<AdministrationTransparencyLogConfig>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
config));
|
||||
})
|
||||
.WithName("GetAdministrationTrustTransparencyLog")
|
||||
.WithSummary("Get trust transparency log configuration")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapPut("/transparency-log", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await store.ConfigureTransparencyLogAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(config);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("ConfigureAdministrationTrustTransparencyLog")
|
||||
.WithSummary("Configure trust transparency log")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult MapStoreError(InvalidOperationException exception, Guid? keyId, Guid? certificateId)
|
||||
{
|
||||
return exception.Message switch
|
||||
{
|
||||
"request_required" => Results.BadRequest(new { error = "request_required" }),
|
||||
"tenant_required" => Results.BadRequest(new { error = "tenant_required" }),
|
||||
"tenant_id_invalid" => Results.BadRequest(new { error = "tenant_id_invalid" }),
|
||||
"reason_required" => Results.BadRequest(new { error = "reason_required" }),
|
||||
"key_alias_required" => Results.BadRequest(new { error = "key_alias_required" }),
|
||||
"key_algorithm_required" => Results.BadRequest(new { error = "key_algorithm_required" }),
|
||||
"key_alias_exists" => Results.Conflict(new { error = "key_alias_exists" }),
|
||||
"key_not_found" => Results.NotFound(new { error = "key_not_found", keyId }),
|
||||
"key_revoked" => Results.Conflict(new { error = "key_revoked", keyId }),
|
||||
"issuer_name_required" => Results.BadRequest(new { error = "issuer_name_required" }),
|
||||
"issuer_uri_required" => Results.BadRequest(new { error = "issuer_uri_required" }),
|
||||
"issuer_uri_invalid" => Results.BadRequest(new { error = "issuer_uri_invalid" }),
|
||||
"issuer_trust_level_required" => Results.BadRequest(new { error = "issuer_trust_level_required" }),
|
||||
"issuer_uri_exists" => Results.Conflict(new { error = "issuer_uri_exists" }),
|
||||
"issuer_not_found" => Results.NotFound(new { error = "issuer_not_found" }),
|
||||
"certificate_serial_required" => Results.BadRequest(new { error = "certificate_serial_required" }),
|
||||
"certificate_validity_invalid" => Results.BadRequest(new { error = "certificate_validity_invalid" }),
|
||||
"certificate_serial_exists" => Results.Conflict(new { error = "certificate_serial_exists" }),
|
||||
"certificate_not_found" => Results.NotFound(new { error = "certificate_not_found", certificateId }),
|
||||
"transparency_log_url_required" => Results.BadRequest(new { error = "transparency_log_url_required" }),
|
||||
"transparency_log_url_invalid" => Results.BadRequest(new { error = "transparency_log_url_invalid" }),
|
||||
"transparency_witness_url_invalid" => Results.BadRequest(new { error = "transparency_witness_url_invalid" }),
|
||||
_ => Results.BadRequest(new { error = exception.Message })
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Intelligence;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class FederationTelemetryEndpoints
|
||||
{
|
||||
// In-memory bundle store for MVP; production would use persistent store
|
||||
private static readonly List<FederatedBundle> _bundles = new();
|
||||
private static readonly object _bundleLock = new();
|
||||
|
||||
public static IEndpointRouteBuilder MapFederationTelemetryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/telemetry/federation")
|
||||
.WithTags("Federated Telemetry");
|
||||
|
||||
// GET /consent — get consent state
|
||||
group.MapGet("/consent", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var state = await consentManager.GetConsentStateAsync(requestContext!.TenantId, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationConsentStateResponse(
|
||||
state.Granted, state.GrantedBy, state.GrantedAt, state.ExpiresAt, state.DsseDigest));
|
||||
})
|
||||
.WithName("GetFederationConsent")
|
||||
.WithSummary("Get federation consent state for current tenant")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// POST /consent/grant — grant consent
|
||||
group.MapPost("/consent/grant", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
FederationGrantConsentRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
TimeSpan? ttl = request.TtlHours.HasValue
|
||||
? TimeSpan.FromHours(request.TtlHours.Value)
|
||||
: null;
|
||||
|
||||
var proof = await consentManager.GrantConsentAsync(
|
||||
requestContext!.TenantId, request.GrantedBy, ttl, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationConsentProofResponse(
|
||||
proof.TenantId, proof.GrantedBy, proof.GrantedAt, proof.ExpiresAt, proof.DsseDigest));
|
||||
})
|
||||
.WithName("GrantFederationConsent")
|
||||
.WithSummary("Grant federation telemetry consent")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
// POST /consent/revoke — revoke consent
|
||||
group.MapPost("/consent/revoke", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
FederationRevokeConsentRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
await consentManager.RevokeConsentAsync(requestContext!.TenantId, request.RevokedBy, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { revoked = true });
|
||||
})
|
||||
.WithName("RevokeFederationConsent")
|
||||
.WithSummary("Revoke federation telemetry consent")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
// GET /status — federation status
|
||||
group.MapGet("/status", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IConsentManager consentManager,
|
||||
IPrivacyBudgetTracker budgetTracker,
|
||||
Microsoft.Extensions.Options.IOptions<Telemetry.Federation.FederatedTelemetryOptions> fedOptions,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var consent = await consentManager.GetConsentStateAsync(requestContext!.TenantId, ct).ConfigureAwait(false);
|
||||
var snapshot = budgetTracker.GetSnapshot();
|
||||
|
||||
int bundleCount;
|
||||
lock (_bundleLock) { bundleCount = _bundles.Count; }
|
||||
|
||||
return Results.Ok(new FederationStatusResponse(
|
||||
Enabled: !fedOptions.Value.SealedModeEnabled,
|
||||
SealedMode: fedOptions.Value.SealedModeEnabled,
|
||||
SiteId: fedOptions.Value.SiteId,
|
||||
ConsentGranted: consent.Granted,
|
||||
EpsilonRemaining: snapshot.Remaining,
|
||||
EpsilonTotal: snapshot.Total,
|
||||
BudgetExhausted: snapshot.Exhausted,
|
||||
NextBudgetReset: snapshot.NextReset,
|
||||
BundleCount: bundleCount));
|
||||
})
|
||||
.WithName("GetFederationStatus")
|
||||
.WithSummary("Get federation telemetry status")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /bundles — list bundles
|
||||
group.MapGet("/bundles", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
List<FederationBundleSummary> summaries;
|
||||
lock (_bundleLock)
|
||||
{
|
||||
summaries = _bundles.Select(b => new FederationBundleSummary(
|
||||
b.Id, b.SourceSiteId,
|
||||
b.Aggregation.Buckets.Count,
|
||||
b.Aggregation.SuppressedBuckets,
|
||||
b.Aggregation.EpsilonSpent,
|
||||
Verified: true,
|
||||
b.CreatedAt)).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult(Results.Ok(summaries));
|
||||
})
|
||||
.WithName("ListFederationBundles")
|
||||
.WithSummary("List federation telemetry bundles")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /bundles/{id} — bundle detail
|
||||
group.MapGet("/bundles/{id:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IFederatedTelemetryBundleBuilder bundleBuilder,
|
||||
Guid id,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
FederatedBundle? bundle;
|
||||
lock (_bundleLock) { bundle = _bundles.FirstOrDefault(b => b.Id == id); }
|
||||
|
||||
if (bundle is null)
|
||||
return Results.NotFound(new { error = "bundle_not_found", id });
|
||||
|
||||
var verified = await bundleBuilder.VerifyAsync(bundle, ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationBundleDetailResponse(
|
||||
bundle.Id, bundle.SourceSiteId,
|
||||
bundle.Aggregation.TotalFacts,
|
||||
bundle.Aggregation.Buckets.Count,
|
||||
bundle.Aggregation.SuppressedBuckets,
|
||||
bundle.Aggregation.EpsilonSpent,
|
||||
bundle.ConsentDsseDigest,
|
||||
bundle.BundleDsseDigest,
|
||||
verified,
|
||||
bundle.Aggregation.AggregatedAt,
|
||||
bundle.CreatedAt,
|
||||
bundle.Aggregation.Buckets.Select(b => new FederationBucketDetail(
|
||||
b.CveId, b.ObservationCount, b.ArtifactCount, b.NoisyCount, b.Suppressed)).ToList()));
|
||||
})
|
||||
.WithName("GetFederationBundle")
|
||||
.WithSummary("Get federation telemetry bundle detail")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /intelligence — exploit corpus
|
||||
group.MapGet("/intelligence", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IExploitIntelligenceMerger intelligenceMerger,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
var corpus = await intelligenceMerger.GetCorpusAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new FederationIntelligenceResponse(
|
||||
corpus.Entries.Select(e => new FederationIntelligenceEntry(
|
||||
e.CveId, e.SourceSiteId, e.ObservationCount, e.NoisyCount, e.ArtifactCount, e.ObservedAt)).ToList(),
|
||||
corpus.TotalEntries,
|
||||
corpus.UniqueCves,
|
||||
corpus.ContributingSites,
|
||||
corpus.LastUpdated));
|
||||
})
|
||||
.WithName("GetFederationIntelligence")
|
||||
.WithSummary("Get shared exploit intelligence corpus")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /privacy-budget — budget snapshot
|
||||
group.MapGet("/privacy-budget", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPrivacyBudgetTracker budgetTracker) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
var snapshot = budgetTracker.GetSnapshot();
|
||||
|
||||
return Task.FromResult(Results.Ok(new FederationPrivacyBudgetResponse(
|
||||
snapshot.Remaining, snapshot.Total, snapshot.Exhausted,
|
||||
snapshot.PeriodStart, snapshot.NextReset,
|
||||
snapshot.QueriesThisPeriod, snapshot.SuppressedThisPeriod)));
|
||||
})
|
||||
.WithName("GetFederationPrivacyBudget")
|
||||
.WithSummary("Get privacy budget snapshot")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// POST /trigger — trigger aggregation
|
||||
group.MapPost("/trigger", Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IPrivacyBudgetTracker budgetTracker) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return Task.FromResult(failure!);
|
||||
|
||||
if (budgetTracker.IsBudgetExhausted)
|
||||
{
|
||||
return Task.FromResult(Results.Ok(new FederationTriggerResponse(
|
||||
Triggered: false,
|
||||
Reason: "Privacy budget exhausted")));
|
||||
}
|
||||
|
||||
// Placeholder: actual implementation would trigger sync service
|
||||
return Task.FromResult(Results.Ok(new FederationTriggerResponse(
|
||||
Triggered: true,
|
||||
Reason: null)));
|
||||
})
|
||||
.WithName("TriggerFederationAggregation")
|
||||
.WithSummary("Trigger manual federation aggregation cycle")
|
||||
.RequireAuthorization(PlatformPolicies.FederationManage);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Pack-driven adapter endpoints for Dashboard, Platform Ops, and Administration views.
|
||||
/// </summary>
|
||||
public static class PackAdapterEndpoints
|
||||
{
|
||||
private static readonly DateTimeOffset SnapshotAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
|
||||
|
||||
public static IEndpointRouteBuilder MapPackAdapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/dashboard/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDashboardSummary();
|
||||
return Results.Ok(new PlatformItemResponse<DashboardSummaryDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithTags("Dashboard")
|
||||
.WithName("GetDashboardSummary")
|
||||
.WithSummary("Pack v2 dashboard summary projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
var platform = app.MapGroup("/api/v1/platform")
|
||||
.WithTags("Platform Ops");
|
||||
|
||||
platform.MapGet("/data-integrity/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDataIntegritySummary();
|
||||
return Results.Ok(new PlatformItemResponse<DataIntegritySummaryDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetDataIntegritySummary")
|
||||
.WithSummary("Pack v2 data-integrity card summary.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/data-integrity/report", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildDataIntegrityReport();
|
||||
return Results.Ok(new PlatformItemResponse<DataIntegrityReportDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetDataIntegrityReport")
|
||||
.WithSummary("Pack v2 nightly data-integrity report projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/feeds/freshness", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var feeds = BuildFeedFreshness();
|
||||
return Results.Ok(new PlatformListResponse<FeedFreshnessDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
feeds,
|
||||
feeds.Count));
|
||||
})
|
||||
.WithName("GetFeedsFreshness")
|
||||
.WithSummary("Pack v2 advisory/feed freshness projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/scan-pipeline/health", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildScanPipelineHealth();
|
||||
return Results.Ok(new PlatformItemResponse<ScanPipelineHealthDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetScanPipelineHealth")
|
||||
.WithSummary("Pack v2 scan-pipeline health projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/reachability/ingest-health", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = BuildReachabilityIngestHealth();
|
||||
return Results.Ok(new PlatformItemResponse<ReachabilityIngestHealthDto>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetReachabilityIngestHealth")
|
||||
.WithSummary("Pack v2 reachability ingest health projection.")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
var administration = app.MapGroup("/api/v1/administration")
|
||||
.WithTags("Administration");
|
||||
|
||||
administration.MapGet("/summary", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationSummary);
|
||||
})
|
||||
.WithName("GetAdministrationSummary")
|
||||
.WithSummary("Pack v2 administration overview cards.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/identity-access", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationIdentityAccess);
|
||||
})
|
||||
.WithName("GetAdministrationIdentityAccess")
|
||||
.WithSummary("Pack v2 administration A1 identity and access projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/tenant-branding", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationTenantBranding);
|
||||
})
|
||||
.WithName("GetAdministrationTenantBranding")
|
||||
.WithSummary("Pack v2 administration A2 tenant and branding projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/notifications", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationNotifications);
|
||||
})
|
||||
.WithName("GetAdministrationNotifications")
|
||||
.WithSummary("Pack v2 administration A3 notifications projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/usage-limits", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationUsageLimits);
|
||||
})
|
||||
.WithName("GetAdministrationUsageLimits")
|
||||
.WithSummary("Pack v2 administration A4 usage and limits projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/policy-governance", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationPolicyGovernance);
|
||||
})
|
||||
.WithName("GetAdministrationPolicyGovernance")
|
||||
.WithSummary("Pack v2 administration A5 policy governance projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/trust-signing", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationTrustSigning);
|
||||
})
|
||||
.WithName("GetAdministrationTrustSigning")
|
||||
.WithSummary("Pack v2 administration A6 trust and signing projection.")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
administration.MapGet("/system", (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationSystem);
|
||||
})
|
||||
.WithName("GetAdministrationSystem")
|
||||
.WithSummary("Pack v2 administration A7 system projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static DashboardSummaryDto BuildDashboardSummary()
|
||||
{
|
||||
var confidence = BuildConfidenceBadge();
|
||||
var environments = new[]
|
||||
{
|
||||
new EnvironmentRiskSnapshotDto("apac-prod", CriticalReachable: 0, HighReachable: 0, SbomState: "fresh"),
|
||||
new EnvironmentRiskSnapshotDto("eu-prod", CriticalReachable: 0, HighReachable: 1, SbomState: "fresh"),
|
||||
new EnvironmentRiskSnapshotDto("us-prod", CriticalReachable: 2, HighReachable: 1, SbomState: "stale"),
|
||||
new EnvironmentRiskSnapshotDto("us-uat", CriticalReachable: 1, HighReachable: 2, SbomState: "stale"),
|
||||
}.OrderBy(item => item.Environment, StringComparer.Ordinal).ToList();
|
||||
|
||||
var topDrivers = new[]
|
||||
{
|
||||
new DashboardDriverDto("CVE-2026-1234", "user-service", "critical", "reachable"),
|
||||
new DashboardDriverDto("CVE-2026-2222", "billing-worker", "critical", "reachable"),
|
||||
new DashboardDriverDto("CVE-2026-9001", "api-gateway", "high", "not_reachable"),
|
||||
};
|
||||
|
||||
return new DashboardSummaryDto(
|
||||
DataConfidence: confidence,
|
||||
EnvironmentsWithCriticalReachable: 2,
|
||||
TotalCriticalReachable: 3,
|
||||
SbomCoveragePercent: 98.0m,
|
||||
VexCoveragePercent: 62.0m,
|
||||
BlockedApprovals: 2,
|
||||
ExceptionsExpiringSoon: 4,
|
||||
EnvironmentRisk: environments,
|
||||
TopDrivers: topDrivers);
|
||||
}
|
||||
|
||||
private static DataIntegritySummaryDto BuildDataIntegritySummary()
|
||||
{
|
||||
var cards = new[]
|
||||
{
|
||||
new DataIntegritySignalDto("feeds", "Advisory feeds", "warning", "NVD mirror stale by 3h.", "/api/v1/platform/feeds/freshness"),
|
||||
new DataIntegritySignalDto("reachability", "Reachability ingest", "warning", "Runtime ingest lag exceeds policy threshold.", "/api/v1/platform/reachability/ingest-health"),
|
||||
new DataIntegritySignalDto("scan-pipeline", "Scan pipeline", "warning", "Pending SBOM rescans create stale risk windows.", "/api/v1/platform/scan-pipeline/health"),
|
||||
new DataIntegritySignalDto("sbom", "SBOM coverage", "warning", "12 digests missing a fresh scan snapshot.", "/api/v1/platform/data-integrity/report"),
|
||||
}.OrderBy(card => card.Id, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new DataIntegritySummaryDto(
|
||||
Confidence: BuildConfidenceBadge(),
|
||||
Signals: cards);
|
||||
}
|
||||
|
||||
private static DataIntegrityReportDto BuildDataIntegrityReport()
|
||||
{
|
||||
var sections = new[]
|
||||
{
|
||||
new DataIntegrityReportSectionDto(
|
||||
"advisory-feeds",
|
||||
"warning",
|
||||
"NVD and vendor feed freshness lag detected.",
|
||||
["nvd stale by 3h", "vendor feed retry budget exceeded once"]),
|
||||
new DataIntegrityReportSectionDto(
|
||||
"scan-pipeline",
|
||||
"warning",
|
||||
"Scan backlog increased due to transient worker degradation.",
|
||||
["pending sbom rescans: 12", "oldest pending digest age: 26h"]),
|
||||
new DataIntegrityReportSectionDto(
|
||||
"reachability",
|
||||
"warning",
|
||||
"Runtime attestations delayed in one region.",
|
||||
["us-east runtime agents degraded", "eu-west ingest healthy"]),
|
||||
}.OrderBy(section => section.SectionId, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new DataIntegrityReportDto(
|
||||
ReportId: "ops-nightly-2026-02-19",
|
||||
GeneratedAt: SnapshotAt,
|
||||
Window: "2026-02-18T03:15:00Z/2026-02-19T03:15:00Z",
|
||||
Sections: sections,
|
||||
RecommendedActions:
|
||||
[
|
||||
"Prioritize runtime ingest queue drain in us-east.",
|
||||
"Force-feed refresh for NVD source before next approval window.",
|
||||
"Trigger high-risk SBOM rescan profile for stale production digests.",
|
||||
]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FeedFreshnessDto> BuildFeedFreshness()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new FeedFreshnessDto("NVD", "warning", LastSyncedAt: "2026-02-19T00:12:00Z", FreshnessHours: 3, SlaHours: 1),
|
||||
new FeedFreshnessDto("OSV", "healthy", LastSyncedAt: "2026-02-19T03:02:00Z", FreshnessHours: 0, SlaHours: 1),
|
||||
new FeedFreshnessDto("Vendor advisories", "healthy", LastSyncedAt: "2026-02-19T02:48:00Z", FreshnessHours: 0, SlaHours: 2),
|
||||
}.OrderBy(feed => feed.Source, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static ScanPipelineHealthDto BuildScanPipelineHealth()
|
||||
{
|
||||
var stages = new[]
|
||||
{
|
||||
new PipelineStageHealthDto("ingest", "healthy", QueueDepth: 12, OldestAgeMinutes: 8),
|
||||
new PipelineStageHealthDto("normalize", "healthy", QueueDepth: 3, OldestAgeMinutes: 4),
|
||||
new PipelineStageHealthDto("rescan", "warning", QueueDepth: 12, OldestAgeMinutes: 1570),
|
||||
}.OrderBy(stage => stage.Stage, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new ScanPipelineHealthDto(
|
||||
Status: "warning",
|
||||
PendingDigests: 12,
|
||||
FailedJobs24h: 3,
|
||||
Stages: stages);
|
||||
}
|
||||
|
||||
private static ReachabilityIngestHealthDto BuildReachabilityIngestHealth()
|
||||
{
|
||||
var regions = new[]
|
||||
{
|
||||
new RegionIngestHealthDto("apac", "healthy", Backlog: 7, FreshnessMinutes: 6),
|
||||
new RegionIngestHealthDto("eu-west", "healthy", Backlog: 11, FreshnessMinutes: 7),
|
||||
new RegionIngestHealthDto("us-east", "warning", Backlog: 1230, FreshnessMinutes: 42),
|
||||
}.OrderBy(region => region.Region, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new ReachabilityIngestHealthDto(
|
||||
Status: "warning",
|
||||
RuntimeCoveragePercent: 41,
|
||||
Regions: regions);
|
||||
}
|
||||
|
||||
private static AdministrationSummaryDto BuildAdministrationSummary()
|
||||
{
|
||||
var domains = new[]
|
||||
{
|
||||
new AdministrationDomainCardDto("identity", "Identity & Access", "healthy", "Role assignments and API tokens are within policy.", "/administration/identity-access"),
|
||||
new AdministrationDomainCardDto("notifications", "Notifications", "healthy", "All configured notification providers are operational.", "/administration/notifications"),
|
||||
new AdministrationDomainCardDto("policy", "Policy Governance", "warning", "One policy bundle update is pending review.", "/administration/policy-governance"),
|
||||
new AdministrationDomainCardDto("system", "System", "healthy", "Control plane services report healthy heartbeat.", "/administration/system"),
|
||||
new AdministrationDomainCardDto("tenant", "Tenant & Branding", "healthy", "Tenant branding and domain mappings are current.", "/administration/tenant-branding"),
|
||||
new AdministrationDomainCardDto("trust", "Trust & Signing", "warning", "One certificate expires within 10 days.", "/administration/trust-signing"),
|
||||
new AdministrationDomainCardDto("usage", "Usage & Limits", "warning", "Scanner quota at 65% with upward trend.", "/administration/usage"),
|
||||
}.OrderBy(domain => domain.DomainId, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new AdministrationSummaryDto(
|
||||
Domains: domains,
|
||||
ActiveIncidents:
|
||||
[
|
||||
"trust/certificate-expiry-warning",
|
||||
"usage/scanner-quota-warning",
|
||||
]);
|
||||
}
|
||||
|
||||
private static AdministrationIdentityAccessDto BuildAdministrationIdentityAccess()
|
||||
{
|
||||
var tabs = new[]
|
||||
{
|
||||
new AdministrationFacetTabDto("api-tokens", "API Tokens", Count: 12, Status: "warning", ActionPath: "/administration/identity-access/tokens"),
|
||||
new AdministrationFacetTabDto("oauth-clients", "OAuth/SSO Clients", Count: 6, Status: "healthy", ActionPath: "/administration/identity-access/clients"),
|
||||
new AdministrationFacetTabDto("roles", "Roles", Count: 18, Status: "healthy", ActionPath: "/administration/identity-access/roles"),
|
||||
new AdministrationFacetTabDto("tenants", "Tenants", Count: 4, Status: "healthy", ActionPath: "/administration/identity-access/tenants"),
|
||||
new AdministrationFacetTabDto("users", "Users", Count: 146, Status: "healthy", ActionPath: "/administration/identity-access/users"),
|
||||
}.OrderBy(tab => tab.TabId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var actors = new[]
|
||||
{
|
||||
new IdentityAccessActorDto("alice@core.example", "release-approver", "active", "2026-02-19T01:22:00Z"),
|
||||
new IdentityAccessActorDto("jenkins-bot", "ci-bot", "active", "2026-02-19T02:17:00Z"),
|
||||
new IdentityAccessActorDto("security-admin@core.example", "security-admin", "active", "2026-02-19T02:59:00Z"),
|
||||
}.OrderBy(actor => actor.Actor, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/clients", "/administration/identity-access/clients", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/roles", "/administration/identity-access/roles", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/tenants", "/administration/identity-access/tenants", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/tokens", "/administration/identity-access/tokens", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/admin/users", "/administration/identity-access/users", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationIdentityAccessDto(
|
||||
Tabs: tabs,
|
||||
RecentActors: actors,
|
||||
LegacyAliases: aliases,
|
||||
AuditLogPath: "/evidence-audit/audit");
|
||||
}
|
||||
|
||||
private static AdministrationTenantBrandingDto BuildAdministrationTenantBranding()
|
||||
{
|
||||
var tenants = new[]
|
||||
{
|
||||
new AdministrationTenantDto("apac-core", "Core APAC", "apac.core.example", "core-pack-v7", "active"),
|
||||
new AdministrationTenantDto("eu-core", "Core EU", "eu.core.example", "core-pack-v7", "active"),
|
||||
new AdministrationTenantDto("us-core", "Core US", "us.core.example", "core-pack-v7", "active"),
|
||||
}.OrderBy(tenant => tenant.TenantId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/branding", "/administration/tenant-branding", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationTenantBrandingDto(
|
||||
Tenants: tenants,
|
||||
BrandingDefaults: new TenantBrandingDefaultsDto(
|
||||
Theme: "light",
|
||||
SupportUrl: "https://support.core.example",
|
||||
LegalFooterVersion: "2026.02"),
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationNotificationsDto BuildAdministrationNotifications()
|
||||
{
|
||||
var rules = new[]
|
||||
{
|
||||
new AdministrationNotificationRuleDto("critical-reachable", "Critical reachable finding", "high", "active"),
|
||||
new AdministrationNotificationRuleDto("gate-blocked", "Gate blocked release", "high", "active"),
|
||||
new AdministrationNotificationRuleDto("quota-warning", "Quota warning", "medium", "active"),
|
||||
}.OrderBy(rule => rule.RuleId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var channels = new[]
|
||||
{
|
||||
new AdministrationNotificationChannelDto("email", "healthy", "2026-02-19T02:40:00Z"),
|
||||
new AdministrationNotificationChannelDto("slack", "healthy", "2026-02-19T02:41:00Z"),
|
||||
new AdministrationNotificationChannelDto("webhook", "warning", "2026-02-19T01:58:00Z"),
|
||||
}.OrderBy(channel => channel.ChannelId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/notifications", "/administration/notifications", "redirect"),
|
||||
new AdministrationRouteAliasDto("/operations/notifications", "/administration/notifications", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/notifications/*", "/administration/notifications/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationNotificationsDto(
|
||||
Rules: rules,
|
||||
Channels: channels,
|
||||
ChannelManagementPath: "/integrations/notifications",
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationUsageLimitsDto BuildAdministrationUsageLimits()
|
||||
{
|
||||
var meters = new[]
|
||||
{
|
||||
new AdministrationUsageMeterDto("api-calls", "API calls", Used: 15000, Limit: 100000, Unit: "calls"),
|
||||
new AdministrationUsageMeterDto("evidence-packets", "Evidence packets", Used: 2800, Limit: 10000, Unit: "packets"),
|
||||
new AdministrationUsageMeterDto("scanner-runs", "Scanner runs", Used: 6500, Limit: 10000, Unit: "runs"),
|
||||
new AdministrationUsageMeterDto("storage", "Storage", Used: 42, Limit: 100, Unit: "GB"),
|
||||
}.OrderBy(meter => meter.MeterId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var policies = new[]
|
||||
{
|
||||
new AdministrationUsagePolicyDto("api-burst", "API burst throttle", "enabled"),
|
||||
new AdministrationUsagePolicyDto("integration-cap", "Per-integration cap", "enabled"),
|
||||
new AdministrationUsagePolicyDto("scanner-quota", "Scanner daily quota", "warning"),
|
||||
}.OrderBy(policy => policy.PolicyId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/settings/admin/:page", "/administration/:page", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationUsageLimitsDto(
|
||||
Meters: meters,
|
||||
Policies: policies,
|
||||
OperationsDrilldownPath: "/platform-ops/quotas",
|
||||
LegacyAliases: aliases);
|
||||
}
|
||||
|
||||
private static AdministrationPolicyGovernanceDto BuildAdministrationPolicyGovernance()
|
||||
{
|
||||
var baselines = new[]
|
||||
{
|
||||
new AdministrationPolicyBaselineDto("dev", "core-pack-v7", "active"),
|
||||
new AdministrationPolicyBaselineDto("prod", "core-pack-v7", "active"),
|
||||
new AdministrationPolicyBaselineDto("stage", "core-pack-v7", "active"),
|
||||
}.OrderBy(baseline => baseline.Environment, StringComparer.Ordinal).ToList();
|
||||
|
||||
var signals = new[]
|
||||
{
|
||||
new AdministrationPolicySignalDto("exception-workflow", "warning", "2 pending exception approvals"),
|
||||
new AdministrationPolicySignalDto("governance-rules", "healthy", "Reachable-critical gate enforced"),
|
||||
new AdministrationPolicySignalDto("simulation", "healthy", "Last what-if simulation completed successfully"),
|
||||
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/policy/governance", "/administration/policy-governance", "redirect"),
|
||||
new AdministrationRouteAliasDto("/admin/policy/simulation", "/administration/policy-governance/simulation", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/exceptions/*", "/administration/policy-governance/exceptions/*", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/governance", "/administration/policy-governance", "redirect"),
|
||||
new AdministrationRouteAliasDto("/policy/packs/*", "/administration/policy-governance/packs/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationPolicyGovernanceDto(
|
||||
Baselines: baselines,
|
||||
Signals: signals,
|
||||
LegacyAliases: aliases,
|
||||
CrossLinks:
|
||||
[
|
||||
"/release-control/approvals",
|
||||
"/administration/policy/exceptions",
|
||||
]);
|
||||
}
|
||||
|
||||
private static AdministrationTrustSigningDto BuildAdministrationTrustSigning()
|
||||
{
|
||||
var signals = new[]
|
||||
{
|
||||
new AdministrationTrustSignalDto("audit-log", "healthy", "Audit log ingestion is current."),
|
||||
new AdministrationTrustSignalDto("certificate-expiry", "warning", "1 certificate expires within 10 days."),
|
||||
new AdministrationTrustSignalDto("transparency-log", "healthy", "Rekor witness is reachable."),
|
||||
new AdministrationTrustSignalDto("trust-scoring", "healthy", "Issuer trust score recalculation completed."),
|
||||
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/admin/issuers", "/administration/trust-signing/issuers", "redirect"),
|
||||
new AdministrationRouteAliasDto("/admin/trust/*", "/administration/trust-signing/*", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/trust/*", "/administration/trust-signing/*", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationTrustSigningDto(
|
||||
Inventory: new AdministrationTrustInventoryDto(Keys: 14, Issuers: 7, Certificates: 23),
|
||||
Signals: signals,
|
||||
LegacyAliases: aliases,
|
||||
EvidenceConsumerPath: "/evidence-audit/proofs");
|
||||
}
|
||||
|
||||
private static AdministrationSystemDto BuildAdministrationSystem()
|
||||
{
|
||||
var controls = new[]
|
||||
{
|
||||
new AdministrationSystemControlDto("background-jobs", "warning", "1 paused job family awaiting manual resume."),
|
||||
new AdministrationSystemControlDto("doctor", "healthy", "Last diagnostics run passed."),
|
||||
new AdministrationSystemControlDto("health-check", "healthy", "All core control-plane services are healthy."),
|
||||
new AdministrationSystemControlDto("slo-config", "healthy", "SLO thresholds are synchronized."),
|
||||
}.OrderBy(control => control.ControlId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
[
|
||||
new AdministrationRouteAliasDto("/operations/status", "/administration/system/status", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/configuration-pane", "/administration/system/configuration", "redirect"),
|
||||
new AdministrationRouteAliasDto("/settings/workflows/*", "/administration/system/workflows", "redirect"),
|
||||
]);
|
||||
|
||||
return new AdministrationSystemDto(
|
||||
OverallStatus: "healthy",
|
||||
Controls: controls,
|
||||
LegacyAliases: aliases,
|
||||
Drilldowns:
|
||||
[
|
||||
"/platform-ops/health",
|
||||
"/platform-ops/orchestrator/jobs",
|
||||
"/platform-ops/data-integrity",
|
||||
]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdministrationRouteAliasDto> BuildAdministrationAliases(
|
||||
AdministrationRouteAliasDto[] aliases)
|
||||
{
|
||||
return aliases
|
||||
.OrderBy(alias => alias.LegacyPath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static DataConfidenceBadgeDto BuildConfidenceBadge()
|
||||
{
|
||||
return new DataConfidenceBadgeDto(
|
||||
Status: "warning",
|
||||
Summary: "NVD freshness and runtime ingest lag reduce confidence.",
|
||||
NvdStalenessHours: 3,
|
||||
StaleSbomDigests: 12,
|
||||
RuntimeDlqDepth: 1230);
|
||||
}
|
||||
|
||||
private static IResult BuildAdministrationItem<T>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
Func<T> payloadFactory)
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = payloadFactory();
|
||||
return Results.Ok(new PlatformItemResponse<T>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DashboardSummaryDto(
|
||||
DataConfidenceBadgeDto DataConfidence,
|
||||
int EnvironmentsWithCriticalReachable,
|
||||
int TotalCriticalReachable,
|
||||
decimal SbomCoveragePercent,
|
||||
decimal VexCoveragePercent,
|
||||
int BlockedApprovals,
|
||||
int ExceptionsExpiringSoon,
|
||||
IReadOnlyList<EnvironmentRiskSnapshotDto> EnvironmentRisk,
|
||||
IReadOnlyList<DashboardDriverDto> TopDrivers);
|
||||
|
||||
public sealed record EnvironmentRiskSnapshotDto(
|
||||
string Environment,
|
||||
int CriticalReachable,
|
||||
int HighReachable,
|
||||
string SbomState);
|
||||
|
||||
public sealed record DashboardDriverDto(
|
||||
string Cve,
|
||||
string Component,
|
||||
string Severity,
|
||||
string Reachability);
|
||||
|
||||
public sealed record DataIntegritySummaryDto(
|
||||
DataConfidenceBadgeDto Confidence,
|
||||
IReadOnlyList<DataIntegritySignalDto> Signals);
|
||||
|
||||
public sealed record DataIntegritySignalDto(
|
||||
string Id,
|
||||
string Label,
|
||||
string Status,
|
||||
string Summary,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record DataIntegrityReportDto(
|
||||
string ReportId,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string Window,
|
||||
IReadOnlyList<DataIntegrityReportSectionDto> Sections,
|
||||
IReadOnlyList<string> RecommendedActions);
|
||||
|
||||
public sealed record DataIntegrityReportSectionDto(
|
||||
string SectionId,
|
||||
string Status,
|
||||
string Summary,
|
||||
IReadOnlyList<string> Highlights);
|
||||
|
||||
public sealed record FeedFreshnessDto(
|
||||
string Source,
|
||||
string Status,
|
||||
string LastSyncedAt,
|
||||
int FreshnessHours,
|
||||
int SlaHours);
|
||||
|
||||
public sealed record ScanPipelineHealthDto(
|
||||
string Status,
|
||||
int PendingDigests,
|
||||
int FailedJobs24h,
|
||||
IReadOnlyList<PipelineStageHealthDto> Stages);
|
||||
|
||||
public sealed record PipelineStageHealthDto(
|
||||
string Stage,
|
||||
string Status,
|
||||
int QueueDepth,
|
||||
int OldestAgeMinutes);
|
||||
|
||||
public sealed record ReachabilityIngestHealthDto(
|
||||
string Status,
|
||||
int RuntimeCoveragePercent,
|
||||
IReadOnlyList<RegionIngestHealthDto> Regions);
|
||||
|
||||
public sealed record RegionIngestHealthDto(
|
||||
string Region,
|
||||
string Status,
|
||||
int Backlog,
|
||||
int FreshnessMinutes);
|
||||
|
||||
public sealed record AdministrationSummaryDto(
|
||||
IReadOnlyList<AdministrationDomainCardDto> Domains,
|
||||
IReadOnlyList<string> ActiveIncidents);
|
||||
|
||||
public sealed record AdministrationDomainCardDto(
|
||||
string DomainId,
|
||||
string Label,
|
||||
string Status,
|
||||
string Summary,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record AdministrationIdentityAccessDto(
|
||||
IReadOnlyList<AdministrationFacetTabDto> Tabs,
|
||||
IReadOnlyList<IdentityAccessActorDto> RecentActors,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
string AuditLogPath);
|
||||
|
||||
public sealed record AdministrationFacetTabDto(
|
||||
string TabId,
|
||||
string Label,
|
||||
int Count,
|
||||
string Status,
|
||||
string ActionPath);
|
||||
|
||||
public sealed record IdentityAccessActorDto(
|
||||
string Actor,
|
||||
string Role,
|
||||
string Status,
|
||||
string LastSeenAt);
|
||||
|
||||
public sealed record AdministrationTenantBrandingDto(
|
||||
IReadOnlyList<AdministrationTenantDto> Tenants,
|
||||
TenantBrandingDefaultsDto BrandingDefaults,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationTenantDto(
|
||||
string TenantId,
|
||||
string DisplayName,
|
||||
string PrimaryDomain,
|
||||
string DefaultPolicyPack,
|
||||
string Status);
|
||||
|
||||
public sealed record TenantBrandingDefaultsDto(
|
||||
string Theme,
|
||||
string SupportUrl,
|
||||
string LegalFooterVersion);
|
||||
|
||||
public sealed record AdministrationNotificationsDto(
|
||||
IReadOnlyList<AdministrationNotificationRuleDto> Rules,
|
||||
IReadOnlyList<AdministrationNotificationChannelDto> Channels,
|
||||
string ChannelManagementPath,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationNotificationRuleDto(
|
||||
string RuleId,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationNotificationChannelDto(
|
||||
string ChannelId,
|
||||
string Status,
|
||||
string LastDeliveredAt);
|
||||
|
||||
public sealed record AdministrationUsageLimitsDto(
|
||||
IReadOnlyList<AdministrationUsageMeterDto> Meters,
|
||||
IReadOnlyList<AdministrationUsagePolicyDto> Policies,
|
||||
string OperationsDrilldownPath,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases);
|
||||
|
||||
public sealed record AdministrationUsageMeterDto(
|
||||
string MeterId,
|
||||
string Label,
|
||||
int Used,
|
||||
int Limit,
|
||||
string Unit);
|
||||
|
||||
public sealed record AdministrationUsagePolicyDto(
|
||||
string PolicyId,
|
||||
string Label,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationPolicyGovernanceDto(
|
||||
IReadOnlyList<AdministrationPolicyBaselineDto> Baselines,
|
||||
IReadOnlyList<AdministrationPolicySignalDto> Signals,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
IReadOnlyList<string> CrossLinks);
|
||||
|
||||
public sealed record AdministrationPolicyBaselineDto(
|
||||
string Environment,
|
||||
string PolicyPack,
|
||||
string Status);
|
||||
|
||||
public sealed record AdministrationPolicySignalDto(
|
||||
string SignalId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationTrustSigningDto(
|
||||
AdministrationTrustInventoryDto Inventory,
|
||||
IReadOnlyList<AdministrationTrustSignalDto> Signals,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
string EvidenceConsumerPath);
|
||||
|
||||
public sealed record AdministrationTrustInventoryDto(
|
||||
int Keys,
|
||||
int Issuers,
|
||||
int Certificates);
|
||||
|
||||
public sealed record AdministrationTrustSignalDto(
|
||||
string SignalId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationSystemDto(
|
||||
string OverallStatus,
|
||||
IReadOnlyList<AdministrationSystemControlDto> Controls,
|
||||
IReadOnlyList<AdministrationRouteAliasDto> LegacyAliases,
|
||||
IReadOnlyList<string> Drilldowns);
|
||||
|
||||
public sealed record AdministrationSystemControlDto(
|
||||
string ControlId,
|
||||
string Status,
|
||||
string Summary);
|
||||
|
||||
public sealed record AdministrationRouteAliasDto(
|
||||
string LegacyPath,
|
||||
string CanonicalPath,
|
||||
string Action);
|
||||
|
||||
public sealed record DataConfidenceBadgeDto(
|
||||
string Status,
|
||||
string Summary,
|
||||
int NvdStalenessHours,
|
||||
int StaleSbomDigests,
|
||||
int RuntimeDlqDepth);
|
||||
@@ -0,0 +1,330 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release Control bundle lifecycle endpoints consumed by UI v2 shell.
|
||||
/// </summary>
|
||||
public static class ReleaseControlEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapReleaseControlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var bundles = app.MapGroup("/api/v1/release-control/bundles")
|
||||
.WithTags("Release Control");
|
||||
|
||||
bundles.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
var items = await store.ListBundlesAsync(
|
||||
requestContext!.TenantId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseControlBundleSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
})
|
||||
.WithName("ListReleaseControlBundles")
|
||||
.WithSummary("List release control bundles")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await store.GetBundleAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "bundle_not_found", bundleId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ReleaseControlBundleDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseControlBundle")
|
||||
.WithSummary("Get release control bundle by id")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapPost(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.CreateBundleAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{created.Id}";
|
||||
return Results.Created(location, created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId: null, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("CreateReleaseControlBundle")
|
||||
.WithSummary("Create release control bundle")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}/versions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
try
|
||||
{
|
||||
var items = await store.ListVersionsAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseControlBundleVersionSummary>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
items,
|
||||
items.Count,
|
||||
normalizedLimit,
|
||||
normalizedOffset));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("ListReleaseControlBundleVersions")
|
||||
.WithSummary("List bundle versions")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapGet("/{bundleId:guid}/versions/{versionId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var version = await store.GetVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
bundleId,
|
||||
versionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (version is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ReleaseControlBundleVersionDetail>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
version));
|
||||
})
|
||||
.WithName("GetReleaseControlBundleVersion")
|
||||
.WithSummary("Get bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapPost("/{bundleId:guid}/versions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.PublishVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
bundleId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{created.Id}";
|
||||
return Results.Created(location, created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId: null);
|
||||
}
|
||||
})
|
||||
.WithName("PublishReleaseControlBundleVersion")
|
||||
.WithSummary("Publish immutable bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
bundles.MapPost("/{bundleId:guid}/versions/{versionId:guid}/materialize", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IReleaseControlBundleStore store,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var run = await store.MaterializeVersionAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
bundleId,
|
||||
versionId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{versionId}/materialize/{run.RunId}";
|
||||
return Results.Accepted(location, run);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, bundleId, versionId);
|
||||
}
|
||||
})
|
||||
.WithName("MaterializeReleaseControlBundleVersion")
|
||||
.WithSummary("Materialize bundle version")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
|
||||
private static IResult MapStoreError(InvalidOperationException exception, Guid? bundleId, Guid? versionId)
|
||||
{
|
||||
return exception.Message switch
|
||||
{
|
||||
"bundle_not_found" => Results.NotFound(new { error = "bundle_not_found", bundleId }),
|
||||
"bundle_version_not_found" => Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId }),
|
||||
"bundle_slug_exists" => Results.Conflict(new { error = "bundle_slug_exists" }),
|
||||
"bundle_slug_required" => Results.BadRequest(new { error = "bundle_slug_required" }),
|
||||
"bundle_name_required" => Results.BadRequest(new { error = "bundle_name_required" }),
|
||||
"request_required" => Results.BadRequest(new { error = "request_required" }),
|
||||
"tenant_required" => Results.BadRequest(new { error = "tenant_required" }),
|
||||
"tenant_id_invalid" => Results.BadRequest(new { error = "tenant_id_invalid" }),
|
||||
_ => Results.BadRequest(new { error = exception.Message })
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using StellaOps.Telemetry.Federation;
|
||||
using System;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -63,6 +64,11 @@ builder.Services.AddStellaOpsTelemetry(
|
||||
});
|
||||
|
||||
builder.Services.AddTelemetryContextPropagation();
|
||||
builder.Services.AddFederatedTelemetry(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("Platform:Federation").Bind(options);
|
||||
});
|
||||
builder.Services.AddFederatedTelemetrySync();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
@@ -122,6 +128,9 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupRead, PlatformScopes.SetupRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupWrite, PlatformScopes.SetupWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupAdmin, PlatformScopes.SetupAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustRead, PlatformScopes.TrustRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustWrite, PlatformScopes.TrustWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TrustAdmin, PlatformScopes.TrustAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreRead, PlatformScopes.ScoreRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ScoreEvaluate, PlatformScopes.ScoreEvaluate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FunctionMapRead, PlatformScopes.FunctionMapRead);
|
||||
@@ -130,6 +139,10 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyRead, PlatformScopes.PolicyRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyWrite, PlatformScopes.PolicyWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PolicyEvaluate, PlatformScopes.PolicyEvaluate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ReleaseControlRead, PlatformScopes.OrchRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ReleaseControlOperate, PlatformScopes.OrchOperate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationRead, PlatformScopes.FederationRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationManage, PlatformScopes.FederationManage);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -177,11 +190,15 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, InMemoryEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, InMemoryReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
|
||||
}
|
||||
|
||||
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
|
||||
@@ -233,6 +250,10 @@ app.MapAnalyticsEndpoints();
|
||||
app.MapScoreEndpoints();
|
||||
app.MapFunctionMapEndpoints();
|
||||
app.MapPolicyInteropEndpoints();
|
||||
app.MapReleaseControlEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public interface IAdministrationTrustSigningStore
|
||||
{
|
||||
Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public interface IReleaseControlBundleStore
|
||||
{
|
||||
Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class InMemoryAdministrationTrustSigningStore : IAdministrationTrustSigningStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryAdministrationTrustSigningStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Keys.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.Alias, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.KeyId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustKeySummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var alias = NormalizeRequired(request.Alias, "key_alias_required");
|
||||
var algorithm = NormalizeRequired(request.Algorithm, "key_algorithm_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var duplicateAlias = state.Keys.Values.Any(existing =>
|
||||
string.Equals(existing.Alias, alias, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateAlias)
|
||||
{
|
||||
throw new InvalidOperationException("key_alias_exists");
|
||||
}
|
||||
|
||||
var created = new KeyState
|
||||
{
|
||||
KeyId = Guid.NewGuid(),
|
||||
Alias = alias,
|
||||
Algorithm = algorithm,
|
||||
Status = "active",
|
||||
CurrentVersion = 1,
|
||||
MetadataJson = NormalizeOptional(request.MetadataJson) ?? "{}",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Keys[created.KeyId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Keys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (string.Equals(key.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("key_revoked");
|
||||
}
|
||||
|
||||
key.CurrentVersion += 1;
|
||||
key.Status = "active";
|
||||
key.UpdatedAt = now;
|
||||
key.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(key));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Keys.TryGetValue(keyId, out var key))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
key.Status = "revoked";
|
||||
key.UpdatedAt = now;
|
||||
key.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(key));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Issuers.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.IssuerId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustIssuerSummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var name = NormalizeRequired(request.Name, "issuer_name_required");
|
||||
var issuerUri = NormalizeRequired(request.IssuerUri, "issuer_uri_required");
|
||||
var trustLevel = NormalizeRequired(request.TrustLevel, "issuer_trust_level_required");
|
||||
ValidateAbsoluteUri(issuerUri, "issuer_uri_invalid");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var duplicateIssuer = state.Issuers.Values.Any(existing =>
|
||||
string.Equals(existing.IssuerUri, issuerUri, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateIssuer)
|
||||
{
|
||||
throw new InvalidOperationException("issuer_uri_exists");
|
||||
}
|
||||
|
||||
var created = new IssuerState
|
||||
{
|
||||
IssuerId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
IssuerUri = issuerUri,
|
||||
TrustLevel = trustLevel,
|
||||
Status = "active",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Issuers[created.IssuerId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var items = state.Certificates.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(item => item.SerialNumber, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.CertificateId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdministrationTrustCertificateSummary>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var serialNumber = NormalizeRequired(request.SerialNumber, "certificate_serial_required");
|
||||
if (request.NotAfter <= request.NotBefore)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_validity_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (request.KeyId.HasValue && !state.Keys.ContainsKey(request.KeyId.Value))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (request.IssuerId.HasValue && !state.Issuers.ContainsKey(request.IssuerId.Value))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
var duplicateSerial = state.Certificates.Values.Any(existing =>
|
||||
string.Equals(existing.SerialNumber, serialNumber, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateSerial)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_serial_exists");
|
||||
}
|
||||
|
||||
var created = new CertificateState
|
||||
{
|
||||
CertificateId = Guid.NewGuid(),
|
||||
KeyId = request.KeyId,
|
||||
IssuerId = request.IssuerId,
|
||||
SerialNumber = serialNumber,
|
||||
Status = "active",
|
||||
NotBefore = request.NotBefore,
|
||||
NotAfter = request.NotAfter,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actor,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
state.Certificates[created.CertificateId] = created;
|
||||
return Task.FromResult(ToSummary(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Certificates.TryGetValue(certificateId, out var certificate))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_not_found");
|
||||
}
|
||||
|
||||
certificate.Status = "revoked";
|
||||
certificate.UpdatedAt = now;
|
||||
certificate.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(certificate));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(state.TransparencyConfig is null ? null : ToSummary(state.TransparencyConfig));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var logUrl = NormalizeRequired(request.LogUrl, "transparency_log_url_required");
|
||||
ValidateAbsoluteUri(logUrl, "transparency_log_url_invalid");
|
||||
|
||||
var witnessUrl = NormalizeOptional(request.WitnessUrl);
|
||||
if (!string.IsNullOrWhiteSpace(witnessUrl))
|
||||
{
|
||||
ValidateAbsoluteUri(witnessUrl, "transparency_witness_url_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
state.TransparencyConfig = new TransparencyConfigState
|
||||
{
|
||||
LogUrl = logUrl,
|
||||
WitnessUrl = witnessUrl,
|
||||
EnforceInclusion = request.EnforceInclusion,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
return Task.FromResult(ToSummary(state.TransparencyConfig));
|
||||
}
|
||||
}
|
||||
|
||||
private TenantState GetState(string tenantId)
|
||||
{
|
||||
var tenant = NormalizeRequired(tenantId, "tenant_required").ToLowerInvariant();
|
||||
return _states.GetOrAdd(tenant, _ => new TenantState());
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
|
||||
|
||||
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;
|
||||
|
||||
private static void ValidateAbsoluteUri(string value, string errorCode)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdministrationTrustKeySummary ToSummary(KeyState state)
|
||||
{
|
||||
return new AdministrationTrustKeySummary(
|
||||
state.KeyId,
|
||||
state.Alias,
|
||||
state.Algorithm,
|
||||
state.Status,
|
||||
state.CurrentVersion,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTrustIssuerSummary ToSummary(IssuerState state)
|
||||
{
|
||||
return new AdministrationTrustIssuerSummary(
|
||||
state.IssuerId,
|
||||
state.Name,
|
||||
state.IssuerUri,
|
||||
state.TrustLevel,
|
||||
state.Status,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTrustCertificateSummary ToSummary(CertificateState state)
|
||||
{
|
||||
return new AdministrationTrustCertificateSummary(
|
||||
state.CertificateId,
|
||||
state.KeyId,
|
||||
state.IssuerId,
|
||||
state.SerialNumber,
|
||||
state.Status,
|
||||
state.NotBefore,
|
||||
state.NotAfter,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private static AdministrationTransparencyLogConfig ToSummary(TransparencyConfigState state)
|
||||
{
|
||||
return new AdministrationTransparencyLogConfig(
|
||||
state.LogUrl,
|
||||
state.WitnessUrl,
|
||||
state.EnforceInclusion,
|
||||
state.UpdatedAt,
|
||||
state.UpdatedBy);
|
||||
}
|
||||
|
||||
private sealed class TenantState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
|
||||
public Dictionary<Guid, KeyState> Keys { get; } = new();
|
||||
|
||||
public Dictionary<Guid, IssuerState> Issuers { get; } = new();
|
||||
|
||||
public Dictionary<Guid, CertificateState> Certificates { get; } = new();
|
||||
|
||||
public TransparencyConfigState? TransparencyConfig { get; set; }
|
||||
}
|
||||
|
||||
private sealed class KeyState
|
||||
{
|
||||
public Guid KeyId { get; set; }
|
||||
|
||||
public string Alias { get; set; } = string.Empty;
|
||||
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public int CurrentVersion { get; set; }
|
||||
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class IssuerState
|
||||
{
|
||||
public Guid IssuerId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string IssuerUri { get; set; } = string.Empty;
|
||||
|
||||
public string TrustLevel { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class CertificateState
|
||||
{
|
||||
public Guid CertificateId { get; set; }
|
||||
|
||||
public Guid? KeyId { get; set; }
|
||||
|
||||
public Guid? IssuerId { get; set; }
|
||||
|
||||
public string SerialNumber { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset NotBefore { get; set; }
|
||||
|
||||
public DateTimeOffset NotAfter { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class TransparencyConfigState
|
||||
{
|
||||
public string LogUrl { get; set; } = string.Empty;
|
||||
|
||||
public string? WitnessUrl { get; set; }
|
||||
|
||||
public bool EnforceInclusion { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class InMemoryReleaseControlBundleStore : IReleaseControlBundleStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryReleaseControlBundleStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var list = state.Bundles.Values
|
||||
.Select(ToSummary)
|
||||
.OrderBy(bundle => bundle.Name, StringComparer.Ordinal)
|
||||
.ThenBy(bundle => bundle.Id)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleSummary>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(
|
||||
state.Bundles.TryGetValue(bundleId, out var bundle)
|
||||
? ToDetail(bundle)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var slug = NormalizeSlug(request.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_name_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var exists = state.Bundles.Values.Any(bundle =>
|
||||
string.Equals(bundle.Slug, slug, StringComparison.OrdinalIgnoreCase));
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_exists");
|
||||
}
|
||||
|
||||
var created = new BundleState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = slug,
|
||||
Name = request.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = string.IsNullOrWhiteSpace(actorId) ? "system" : actorId
|
||||
};
|
||||
|
||||
state.Bundles[created.Id] = created;
|
||||
return Task.FromResult(ToDetail(created));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var list = bundle.Versions
|
||||
.OrderByDescending(version => version.VersionNumber)
|
||||
.ThenByDescending(version => version.Id)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.Select(ToVersionSummary)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleVersionSummary>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
return Task.FromResult<ReleaseControlBundleVersionDetail?>(null);
|
||||
}
|
||||
|
||||
var version = bundle.Versions.FirstOrDefault(item => item.Id == versionId);
|
||||
return Task.FromResult(version is null ? null : ToVersionDetail(version));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var nextVersion = bundle.Versions.Count == 0
|
||||
? 1
|
||||
: bundle.Versions.Max(version => version.VersionNumber) + 1;
|
||||
|
||||
var digest = ReleaseControlBundleDigest.Compute(
|
||||
bundleId,
|
||||
nextVersion,
|
||||
request.Changelog,
|
||||
normalizedComponents);
|
||||
|
||||
var version = new BundleVersionState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BundleId = bundleId,
|
||||
VersionNumber = nextVersion,
|
||||
Digest = digest,
|
||||
Status = "published",
|
||||
ComponentsCount = normalizedComponents.Count,
|
||||
Changelog = string.IsNullOrWhiteSpace(request.Changelog) ? null : request.Changelog.Trim(),
|
||||
CreatedAt = now,
|
||||
PublishedAt = now,
|
||||
CreatedBy = string.IsNullOrWhiteSpace(actorId) ? "system" : actorId,
|
||||
Components = normalizedComponents
|
||||
.Select(component => new ReleaseControlBundleComponent(
|
||||
component.ComponentVersionId,
|
||||
component.ComponentName,
|
||||
component.ImageDigest,
|
||||
component.DeployOrder,
|
||||
component.MetadataJson ?? "{}"))
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
bundle.Versions.Add(version);
|
||||
bundle.UpdatedAt = now;
|
||||
return Task.FromResult(ToVersionDetail(version));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var state = GetState(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Bundles.TryGetValue(bundleId, out var bundle))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var versionExists = bundle.Versions.Any(version => version.Id == versionId);
|
||||
if (!versionExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
var normalizedKey = NormalizeIdempotencyKey(request.IdempotencyKey);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKey))
|
||||
{
|
||||
var existing = state.Materializations.Values.FirstOrDefault(run =>
|
||||
run.BundleId == bundleId
|
||||
&& run.VersionId == versionId
|
||||
&& string.Equals(run.IdempotencyKey, normalizedKey, StringComparison.Ordinal));
|
||||
if (existing is not null)
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
}
|
||||
|
||||
var created = new ReleaseControlBundleMaterializationRun(
|
||||
Guid.NewGuid(),
|
||||
bundleId,
|
||||
versionId,
|
||||
"queued",
|
||||
NormalizeOptional(request.TargetEnvironment),
|
||||
NormalizeOptional(request.Reason),
|
||||
string.IsNullOrWhiteSpace(actorId) ? "system" : actorId,
|
||||
normalizedKey,
|
||||
now,
|
||||
now);
|
||||
|
||||
state.Materializations[created.RunId] = created;
|
||||
bundle.UpdatedAt = now;
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantState GetState(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
return _states.GetOrAdd(tenantId.Trim().ToLowerInvariant(), _ => new TenantState());
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleSummary ToSummary(BundleState bundle)
|
||||
{
|
||||
var latest = bundle.Versions
|
||||
.OrderByDescending(version => version.VersionNumber)
|
||||
.ThenByDescending(version => version.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ReleaseControlBundleSummary(
|
||||
bundle.Id,
|
||||
bundle.Slug,
|
||||
bundle.Name,
|
||||
bundle.Description,
|
||||
bundle.Versions.Count,
|
||||
latest?.VersionNumber,
|
||||
latest?.Id,
|
||||
latest?.Digest,
|
||||
latest?.PublishedAt,
|
||||
bundle.CreatedAt,
|
||||
bundle.UpdatedAt);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleDetail ToDetail(BundleState bundle)
|
||||
{
|
||||
var summary = ToSummary(bundle);
|
||||
return new ReleaseControlBundleDetail(
|
||||
summary.Id,
|
||||
summary.Slug,
|
||||
summary.Name,
|
||||
summary.Description,
|
||||
summary.TotalVersions,
|
||||
summary.LatestVersionNumber,
|
||||
summary.LatestVersionId,
|
||||
summary.LatestVersionDigest,
|
||||
summary.LatestPublishedAt,
|
||||
summary.CreatedAt,
|
||||
summary.UpdatedAt,
|
||||
bundle.CreatedBy);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionSummary ToVersionSummary(BundleVersionState version)
|
||||
{
|
||||
return new ReleaseControlBundleVersionSummary(
|
||||
version.Id,
|
||||
version.BundleId,
|
||||
version.VersionNumber,
|
||||
version.Digest,
|
||||
version.Status,
|
||||
version.ComponentsCount,
|
||||
version.Changelog,
|
||||
version.CreatedAt,
|
||||
version.PublishedAt,
|
||||
version.CreatedBy);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionDetail ToVersionDetail(BundleVersionState version)
|
||||
{
|
||||
var summary = ToVersionSummary(version);
|
||||
return new ReleaseControlBundleVersionDetail(
|
||||
summary.Id,
|
||||
summary.BundleId,
|
||||
summary.VersionNumber,
|
||||
summary.Digest,
|
||||
summary.Status,
|
||||
summary.ComponentsCount,
|
||||
summary.Changelog,
|
||||
summary.CreatedAt,
|
||||
summary.PublishedAt,
|
||||
summary.CreatedBy,
|
||||
version.Components);
|
||||
}
|
||||
|
||||
private static string NormalizeSlug(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Trim().ToLowerInvariant();
|
||||
var chars = cleaned
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray();
|
||||
var compact = new string(chars);
|
||||
while (compact.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
compact = compact.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return compact.Trim('-');
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeIdempotencyKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private sealed class TenantState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
public Dictionary<Guid, BundleState> Bundles { get; } = new();
|
||||
public Dictionary<Guid, ReleaseControlBundleMaterializationRun> Materializations { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class BundleState
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string CreatedBy { get; init; } = string.Empty;
|
||||
public List<BundleVersionState> Versions { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class BundleVersionState
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid BundleId { get; init; }
|
||||
public int VersionNumber { get; init; }
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "published";
|
||||
public int ComponentsCount { get; init; }
|
||||
public string? Changelog { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public string CreatedBy { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ReleaseControlBundleComponent> Components { get; init; } = Array.Empty<ReleaseControlBundleComponent>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed trust and signing administration store.
|
||||
/// </summary>
|
||||
public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTrustSigningStore
|
||||
{
|
||||
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresAdministrationTrustSigningStore> _logger;
|
||||
|
||||
public PostgresAdministrationTrustSigningStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresAdministrationTrustSigningStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresAdministrationTrustSigningStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustKeySummary>> ListKeysAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY key_alias, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustKeySummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapKeySummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> CreateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var alias = NormalizeRequired(request.Alias, "key_alias_required");
|
||||
var algorithm = NormalizeRequired(request.Algorithm, "key_algorithm_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var keyId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_keys (
|
||||
id,
|
||||
tenant_id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@key_alias,
|
||||
@algorithm,
|
||||
'active',
|
||||
1,
|
||||
@metadata_json::jsonb,
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("key_alias", alias);
|
||||
command.Parameters.AddWithValue("algorithm", algorithm);
|
||||
command.Parameters.AddWithValue("metadata_json", NormalizeOptional(request.MetadataJson) ?? "{}");
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_create_failed");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("key_alias_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> RotateKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RotateAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
var existingStatus = await GetKeyStatusAsync(connection, tenantGuid, keyId, cancellationToken).ConfigureAwait(false);
|
||||
if (existingStatus is null)
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
if (string.Equals(existingStatus, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("key_revoked");
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_keys
|
||||
SET
|
||||
current_version = current_version + 1,
|
||||
status = 'active',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustKeySummary> RevokeKeyAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid keyId,
|
||||
RevokeAdministrationTrustKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_keys
|
||||
SET
|
||||
status = 'revoked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
|
||||
return MapKeySummary(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustIssuerSummary>> ListIssuersAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_issuers
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY issuer_name, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustIssuerSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapIssuerSummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> RegisterIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var name = NormalizeRequired(request.Name, "issuer_name_required");
|
||||
var issuerUri = NormalizeRequired(request.IssuerUri, "issuer_uri_required");
|
||||
ValidateAbsoluteUri(issuerUri, "issuer_uri_invalid");
|
||||
var trustLevel = NormalizeRequired(request.TrustLevel, "issuer_trust_level_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var issuerId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_issuers (
|
||||
id,
|
||||
tenant_id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@issuer_name,
|
||||
@issuer_uri,
|
||||
@trust_level,
|
||||
'active',
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("issuer_name", name);
|
||||
command.Parameters.AddWithValue("issuer_uri", issuerUri);
|
||||
command.Parameters.AddWithValue("trust_level", trustLevel);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_create_failed");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_uri_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_certificates
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY serial_number, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", normalizedLimit);
|
||||
command.Parameters.AddWithValue("offset", normalizedOffset);
|
||||
|
||||
var items = new List<AdministrationTrustCertificateSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(MapCertificateSummary(reader));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustCertificateSummary> RegisterCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
RegisterAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var serialNumber = NormalizeRequired(request.SerialNumber, "certificate_serial_required");
|
||||
if (request.NotAfter <= request.NotBefore)
|
||||
{
|
||||
throw new InvalidOperationException("certificate_validity_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var certificateId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.KeyId.HasValue)
|
||||
{
|
||||
var keyExists = await EntityExistsAsync(
|
||||
connection,
|
||||
"release.trust_keys",
|
||||
tenantGuid,
|
||||
request.KeyId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!keyExists)
|
||||
{
|
||||
throw new InvalidOperationException("key_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.IssuerId.HasValue)
|
||||
{
|
||||
var issuerExists = await EntityExistsAsync(
|
||||
connection,
|
||||
"release.trust_issuers",
|
||||
tenantGuid,
|
||||
request.IssuerId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!issuerExists)
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@key_id,
|
||||
@issuer_id,
|
||||
@serial_number,
|
||||
'active',
|
||||
@not_before,
|
||||
@not_after,
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@created_by,
|
||||
@updated_by
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", certificateId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("key_id", (object?)request.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("issuer_id", (object?)request.IssuerId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("serial_number", serialNumber);
|
||||
command.Parameters.AddWithValue("not_before", request.NotBefore);
|
||||
command.Parameters.AddWithValue("not_after", request.NotAfter);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", actor);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_create_failed");
|
||||
}
|
||||
|
||||
return MapCertificateSummary(reader);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_serial_exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustCertificateSummary> RevokeCertificateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid certificateId,
|
||||
RevokeAdministrationTrustCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_certificates
|
||||
SET
|
||||
status = 'revoked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", certificateId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("certificate_not_found");
|
||||
}
|
||||
|
||||
return MapCertificateSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTransparencyLogConfig?> GetTransparencyLogConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM release.trust_transparency_configs
|
||||
WHERE tenant_id = @tenant_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapTransparencyConfig(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTransparencyLogConfig> ConfigureTransparencyLogAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
ConfigureAdministrationTransparencyLogRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var logUrl = NormalizeRequired(request.LogUrl, "transparency_log_url_required");
|
||||
ValidateAbsoluteUri(logUrl, "transparency_log_url_invalid");
|
||||
var witnessUrl = NormalizeOptional(request.WitnessUrl);
|
||||
if (!string.IsNullOrWhiteSpace(witnessUrl))
|
||||
{
|
||||
ValidateAbsoluteUri(witnessUrl, "transparency_witness_url_invalid");
|
||||
}
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.trust_transparency_configs (
|
||||
tenant_id,
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@log_url,
|
||||
@witness_url,
|
||||
@enforce_inclusion,
|
||||
@updated_at,
|
||||
@updated_by
|
||||
)
|
||||
ON CONFLICT (tenant_id)
|
||||
DO UPDATE SET
|
||||
log_url = EXCLUDED.log_url,
|
||||
witness_url = EXCLUDED.witness_url,
|
||||
enforce_inclusion = EXCLUDED.enforce_inclusion,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("log_url", logUrl);
|
||||
command.Parameters.AddWithValue("witness_url", (object?)witnessUrl ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("enforce_inclusion", request.EnforceInclusion);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("transparency_log_update_failed");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Configured trust transparency log for tenant {TenantId}", tenantGuid);
|
||||
return MapTransparencyConfig(reader);
|
||||
}
|
||||
|
||||
private async Task<string?> GetKeyStatusAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT status
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", keyId);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
private static async Task<bool> EntityExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tableName,
|
||||
Guid tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT 1 FROM {tableName} WHERE tenant_id = @tenant_id AND id = @id LIMIT 1",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var setTenantCommand = new NpgsqlCommand(SetTenantSql, connection);
|
||||
setTenantCommand.Parameters.AddWithValue("tenant_id", tenantId.ToString("D"));
|
||||
await setTenantCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_id_invalid");
|
||||
}
|
||||
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
|
||||
|
||||
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static void ValidateAbsoluteUri(string value, string errorCode)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdministrationTrustKeySummary MapKeySummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustKeySummary(
|
||||
KeyId: reader.GetGuid(0),
|
||||
Alias: reader.GetString(1),
|
||||
Algorithm: reader.GetString(2),
|
||||
Status: reader.GetString(3),
|
||||
CurrentVersion: reader.GetInt32(4),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedBy: reader.GetString(7));
|
||||
}
|
||||
|
||||
private static AdministrationTrustIssuerSummary MapIssuerSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustIssuerSummary(
|
||||
IssuerId: reader.GetGuid(0),
|
||||
Name: reader.GetString(1),
|
||||
IssuerUri: reader.GetString(2),
|
||||
TrustLevel: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedBy: reader.GetString(7));
|
||||
}
|
||||
|
||||
private static AdministrationTrustCertificateSummary MapCertificateSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTrustCertificateSummary(
|
||||
CertificateId: reader.GetGuid(0),
|
||||
KeyId: reader.IsDBNull(1) ? null : reader.GetGuid(1),
|
||||
IssuerId: reader.IsDBNull(2) ? null : reader.GetGuid(2),
|
||||
SerialNumber: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
NotBefore: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
NotAfter: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedBy: reader.GetString(9));
|
||||
}
|
||||
|
||||
private static AdministrationTransparencyLogConfig MapTransparencyConfig(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdministrationTransparencyLogConfig(
|
||||
LogUrl: reader.GetString(0),
|
||||
WitnessUrl: reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
EnforceInclusion: reader.GetBoolean(2),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(3),
|
||||
UpdatedBy: reader.GetString(4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed release control bundle store.
|
||||
/// </summary>
|
||||
public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleStore
|
||||
{
|
||||
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresReleaseControlBundleStore> _logger;
|
||||
|
||||
public PostgresReleaseControlBundleStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresReleaseControlBundleStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresReleaseControlBundleStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleSummary>> ListBundlesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
b.id,
|
||||
b.slug,
|
||||
b.name,
|
||||
b.description,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
COALESCE(v.total_versions, 0) AS total_versions,
|
||||
lv.version_number,
|
||||
lv.id AS latest_version_id,
|
||||
lv.digest,
|
||||
lv.published_at
|
||||
FROM release.control_bundles b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total_versions
|
||||
FROM release.control_bundle_versions v
|
||||
WHERE v.tenant_id = b.tenant_id AND v.bundle_id = b.id
|
||||
) v ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, version_number, digest, published_at
|
||||
FROM release.control_bundle_versions v2
|
||||
WHERE v2.tenant_id = b.tenant_id AND v2.bundle_id = b.id
|
||||
ORDER BY v2.version_number DESC, v2.id DESC
|
||||
LIMIT 1
|
||||
) lv ON true
|
||||
WHERE b.tenant_id = @tenant_id
|
||||
ORDER BY b.name, b.id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new ReleaseControlBundleSummary(
|
||||
Id: reader.GetGuid(0),
|
||||
Slug: reader.GetString(1),
|
||||
Name: reader.GetString(2),
|
||||
Description: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
TotalVersions: reader.GetInt32(6),
|
||||
LatestVersionNumber: reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
LatestVersionId: reader.IsDBNull(8) ? null : reader.GetGuid(8),
|
||||
LatestVersionDigest: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
LatestPublishedAt: reader.IsDBNull(10) ? null : reader.GetFieldValue<DateTimeOffset>(10),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(5)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleDetail?> GetBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
b.id,
|
||||
b.slug,
|
||||
b.name,
|
||||
b.description,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.created_by,
|
||||
COALESCE(v.total_versions, 0) AS total_versions,
|
||||
lv.version_number,
|
||||
lv.id AS latest_version_id,
|
||||
lv.digest,
|
||||
lv.published_at
|
||||
FROM release.control_bundles b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total_versions
|
||||
FROM release.control_bundle_versions v
|
||||
WHERE v.tenant_id = b.tenant_id AND v.bundle_id = b.id
|
||||
) v ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, version_number, digest, published_at
|
||||
FROM release.control_bundle_versions v2
|
||||
WHERE v2.tenant_id = b.tenant_id AND v2.bundle_id = b.id
|
||||
ORDER BY v2.version_number DESC, v2.id DESC
|
||||
LIMIT 1
|
||||
) lv ON true
|
||||
WHERE b.tenant_id = @tenant_id AND b.id = @bundle_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseControlBundleDetail(
|
||||
Id: reader.GetGuid(0),
|
||||
Slug: reader.GetString(1),
|
||||
Name: reader.GetString(2),
|
||||
Description: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
TotalVersions: reader.GetInt32(7),
|
||||
LatestVersionNumber: reader.IsDBNull(8) ? null : reader.GetInt32(8),
|
||||
LatestVersionId: reader.IsDBNull(9) ? null : reader.GetGuid(9),
|
||||
LatestVersionDigest: reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
LatestPublishedAt: reader.IsDBNull(11) ? null : reader.GetFieldValue<DateTimeOffset>(11),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(5),
|
||||
CreatedBy: reader.GetString(6));
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleDetail> CreateBundleAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateReleaseControlBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var slug = NormalizeSlug(request.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_name_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = Guid.NewGuid();
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundles (
|
||||
id, tenant_id, slug, name, description, created_at, updated_at, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @slug, @name, @description, @created_at, @updated_at, @created_by
|
||||
)
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("id", bundleId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("slug", slug);
|
||||
command.Parameters.AddWithValue("name", request.Name.Trim());
|
||||
command.Parameters.AddWithValue("description", (object?)NormalizeOptional(request.Description) ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", now);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("created_by", createdBy);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("bundle_slug_exists");
|
||||
}
|
||||
|
||||
var created = await GetBundleAsync(tenantId, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (created is null)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Created release control bundle {BundleId} for tenant {TenantId}", bundleId, tenantGuid);
|
||||
return created;
|
||||
}
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleVersionSummary>> ListVersionsAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
ORDER BY version_number DESC, id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleVersionSummary>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapVersionSummary(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleVersionDetail?> GetVersionAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var versionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id AND id = @version_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
versionCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
versionCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
versionCommand.Parameters.AddWithValue("version_id", versionId);
|
||||
|
||||
await using var versionReader = await versionCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await versionReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = MapVersionSummary(versionReader);
|
||||
await versionReader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
var components = await ReadComponentsAsync(
|
||||
connection,
|
||||
tenantGuid,
|
||||
bundleId,
|
||||
versionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ReleaseControlBundleVersionDetail(
|
||||
Id: summary.Id,
|
||||
BundleId: summary.BundleId,
|
||||
VersionNumber: summary.VersionNumber,
|
||||
Digest: summary.Digest,
|
||||
Status: summary.Status,
|
||||
ComponentsCount: summary.ComponentsCount,
|
||||
Changelog: summary.Changelog,
|
||||
CreatedAt: summary.CreatedAt,
|
||||
PublishedAt: summary.PublishedAt,
|
||||
CreatedBy: summary.CreatedBy,
|
||||
Components: components);
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleVersionDetail> PublishVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
PublishReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var nextVersionNumber = await GetNextVersionNumberAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
var digest = ReleaseControlBundleDigest.Compute(bundleId, nextVersionNumber, request.Changelog, normalizedComponents);
|
||||
var versionId = Guid.NewGuid();
|
||||
|
||||
await using (var insertVersionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_versions (
|
||||
id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
version_number,
|
||||
digest,
|
||||
status,
|
||||
components_count,
|
||||
changelog,
|
||||
created_at,
|
||||
published_at,
|
||||
created_by
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@version_number,
|
||||
@digest,
|
||||
@status,
|
||||
@components_count,
|
||||
@changelog,
|
||||
@created_at,
|
||||
@published_at,
|
||||
@created_by
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
insertVersionCommand.Parameters.AddWithValue("id", versionId);
|
||||
insertVersionCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertVersionCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertVersionCommand.Parameters.AddWithValue("version_number", nextVersionNumber);
|
||||
insertVersionCommand.Parameters.AddWithValue("digest", digest);
|
||||
insertVersionCommand.Parameters.AddWithValue("status", "published");
|
||||
insertVersionCommand.Parameters.AddWithValue("components_count", normalizedComponents.Count);
|
||||
insertVersionCommand.Parameters.AddWithValue("changelog", (object?)NormalizeOptional(request.Changelog) ?? DBNull.Value);
|
||||
insertVersionCommand.Parameters.AddWithValue("created_at", now);
|
||||
insertVersionCommand.Parameters.AddWithValue("published_at", now);
|
||||
insertVersionCommand.Parameters.AddWithValue("created_by", createdBy);
|
||||
await insertVersionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < normalizedComponents.Count; i++)
|
||||
{
|
||||
var component = normalizedComponents[i];
|
||||
await using var insertComponentCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_components (
|
||||
id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
component_version_id,
|
||||
component_name,
|
||||
image_digest,
|
||||
deploy_order,
|
||||
metadata_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@bundle_version_id,
|
||||
@component_version_id,
|
||||
@component_name,
|
||||
@image_digest,
|
||||
@deploy_order,
|
||||
@metadata_json::jsonb,
|
||||
@created_at
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
insertComponentCommand.Parameters.AddWithValue("id", Guid.NewGuid());
|
||||
insertComponentCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertComponentCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertComponentCommand.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
insertComponentCommand.Parameters.AddWithValue("component_version_id", component.ComponentVersionId);
|
||||
insertComponentCommand.Parameters.AddWithValue("component_name", component.ComponentName);
|
||||
insertComponentCommand.Parameters.AddWithValue("image_digest", component.ImageDigest);
|
||||
insertComponentCommand.Parameters.AddWithValue("deploy_order", component.DeployOrder);
|
||||
insertComponentCommand.Parameters.AddWithValue("metadata_json", component.MetadataJson ?? "{}");
|
||||
insertComponentCommand.Parameters.AddWithValue("created_at", now);
|
||||
await insertComponentCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var version = await GetVersionAsync(tenantId, bundleId, versionId, cancellationToken).ConfigureAwait(false);
|
||||
if (version is null)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Published release control bundle version {VersionId} for bundle {BundleId} tenant {TenantId}",
|
||||
versionId,
|
||||
bundleId,
|
||||
tenantGuid);
|
||||
|
||||
return version;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public async Task<ReleaseControlBundleMaterializationRun> MaterializeVersionAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var requestedBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedIdempotencyKey = NormalizeIdempotencyKey(request.IdempotencyKey);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var bundleExists = await BundleExistsAsync(connection, tenantGuid, bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (!bundleExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_not_found");
|
||||
}
|
||||
|
||||
var versionExists = await VersionExistsAsync(connection, tenantGuid, bundleId, versionId, cancellationToken).ConfigureAwait(false);
|
||||
if (!versionExists)
|
||||
{
|
||||
throw new InvalidOperationException("bundle_version_not_found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedIdempotencyKey))
|
||||
{
|
||||
var existing = await TryGetMaterializationByIdempotencyKeyAsync(
|
||||
connection,
|
||||
tenantGuid,
|
||||
bundleId,
|
||||
versionId,
|
||||
normalizedIdempotencyKey!,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
await using (var insertCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO release.control_bundle_materialization_runs (
|
||||
run_id,
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@run_id,
|
||||
@tenant_id,
|
||||
@bundle_id,
|
||||
@bundle_version_id,
|
||||
@status,
|
||||
@target_environment,
|
||||
@reason,
|
||||
@requested_by,
|
||||
@idempotency_key,
|
||||
@requested_at,
|
||||
@updated_at
|
||||
)
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
insertCommand.Parameters.AddWithValue("run_id", runId);
|
||||
insertCommand.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insertCommand.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
insertCommand.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
insertCommand.Parameters.AddWithValue("status", "queued");
|
||||
insertCommand.Parameters.AddWithValue("target_environment", (object?)NormalizeOptional(request.TargetEnvironment) ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("reason", (object?)NormalizeOptional(request.Reason) ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("requested_by", requestedBy);
|
||||
insertCommand.Parameters.AddWithValue("idempotency_key", (object?)normalizedIdempotencyKey ?? DBNull.Value);
|
||||
insertCommand.Parameters.AddWithValue("requested_at", now);
|
||||
insertCommand.Parameters.AddWithValue("updated_at", now);
|
||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new ReleaseControlBundleMaterializationRun(
|
||||
RunId: runId,
|
||||
BundleId: bundleId,
|
||||
VersionId: versionId,
|
||||
Status: "queued",
|
||||
TargetEnvironment: NormalizeOptional(request.TargetEnvironment),
|
||||
Reason: NormalizeOptional(request.Reason),
|
||||
RequestedBy: requestedBy,
|
||||
IdempotencyKey: normalizedIdempotencyKey,
|
||||
RequestedAt: now,
|
||||
UpdatedAt: now);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionSummary MapVersionSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ReleaseControlBundleVersionSummary(
|
||||
Id: reader.GetGuid(0),
|
||||
BundleId: reader.GetGuid(1),
|
||||
VersionNumber: reader.GetInt32(2),
|
||||
Digest: reader.GetString(3),
|
||||
Status: reader.GetString(4),
|
||||
ComponentsCount: reader.GetInt32(5),
|
||||
Changelog: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
PublishedAt: reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
|
||||
CreatedBy: reader.GetString(9));
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_id_invalid");
|
||||
}
|
||||
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand(SetTenantSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.ToString("D"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> BundleExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM release.control_bundles
|
||||
WHERE tenant_id = @tenant_id AND id = @bundle_id
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
var exists = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return exists is not null;
|
||||
}
|
||||
|
||||
private static async Task<bool> VersionExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id AND id = @version_id
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("version_id", versionId);
|
||||
var exists = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return exists is not null;
|
||||
}
|
||||
|
||||
private static async Task<int> GetNextVersionNumberAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
FROM release.control_bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return value is int intValue ? intValue : Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ReleaseControlBundleComponent>> ReadComponentsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
component_version_id,
|
||||
component_name,
|
||||
image_digest,
|
||||
deploy_order,
|
||||
metadata_json::text
|
||||
FROM release.control_bundle_components
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @bundle_id
|
||||
AND bundle_version_id = @bundle_version_id
|
||||
ORDER BY deploy_order, component_name, component_version_id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
|
||||
var items = new List<ReleaseControlBundleComponent>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new ReleaseControlBundleComponent(
|
||||
ComponentVersionId: reader.GetString(0),
|
||||
ComponentName: reader.GetString(1),
|
||||
ImageDigest: reader.GetString(2),
|
||||
DeployOrder: reader.GetInt32(3),
|
||||
MetadataJson: reader.GetString(4)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static async Task<ReleaseControlBundleMaterializationRun?> TryGetMaterializationByIdempotencyKeyAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid tenantId,
|
||||
Guid bundleId,
|
||||
Guid versionId,
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
run_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
FROM release.control_bundle_materialization_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @bundle_id
|
||||
AND bundle_version_id = @bundle_version_id
|
||||
AND idempotency_key = @idempotency_key
|
||||
ORDER BY requested_at DESC, run_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("bundle_version_id", versionId);
|
||||
command.Parameters.AddWithValue("idempotency_key", idempotencyKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseControlBundleMaterializationRun(
|
||||
RunId: reader.GetGuid(0),
|
||||
BundleId: reader.GetGuid(1),
|
||||
VersionId: reader.GetGuid(2),
|
||||
Status: reader.GetString(3),
|
||||
TargetEnvironment: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Reason: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
RequestedBy: reader.GetString(6),
|
||||
IdempotencyKey: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
RequestedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
|
||||
}
|
||||
|
||||
private static string NormalizeSlug(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Trim().ToLowerInvariant();
|
||||
var chars = cleaned.Select(static ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray();
|
||||
var compact = new string(chars);
|
||||
while (compact.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
compact = compact.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return compact.Trim('-');
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string actorId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(actorId) ? "system" : actorId.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeIdempotencyKey(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
internal static class ReleaseControlBundleDigest
|
||||
{
|
||||
public static string Compute(
|
||||
Guid bundleId,
|
||||
int versionNumber,
|
||||
string? changelog,
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput> components)
|
||||
{
|
||||
var normalizedComponents = components
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.Select(component =>
|
||||
$"{component.ComponentVersionId}|{component.ComponentName}|{component.ImageDigest}|{component.DeployOrder}|{(component.MetadataJson ?? "{}").Trim()}")
|
||||
.ToArray();
|
||||
|
||||
var payload = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
bundleId.ToString("D"),
|
||||
versionNumber.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
(changelog ?? string.Empty).Trim(),
|
||||
string.Join("\n", normalizedComponents)
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ReleaseControlBundleComponentInput> NormalizeComponents(
|
||||
IReadOnlyList<ReleaseControlBundleComponentInput>? components)
|
||||
{
|
||||
if (components is null || components.Count == 0)
|
||||
{
|
||||
return Array.Empty<ReleaseControlBundleComponentInput>();
|
||||
}
|
||||
|
||||
return components
|
||||
.Select(component => new ReleaseControlBundleComponentInput(
|
||||
component.ComponentVersionId.Trim(),
|
||||
component.ComponentName.Trim(),
|
||||
component.ImageDigest.Trim(),
|
||||
component.DeployOrder,
|
||||
string.IsNullOrWhiteSpace(component.MetadataJson) ? "{}" : component.MetadataJson.Trim()))
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented Pack-21 Administration A1-A7 adapter endpoints under `/api/v1/administration/*` with deterministic migration alias metadata. |
|
||||
| PACK-ADM-02 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented trust owner mutation/read endpoints under `/api/v1/administration/trust-signing/*` with `trust:write`/`trust:admin` policy mapping and DB backing via migration `046_TrustSigningAdministration.sql`. |
|
||||
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
-- Migration: 045_ReleaseControlBundleLifecycle
|
||||
-- Purpose: Add release-control bundle lifecycle persistence for UI v2 shell contracts.
|
||||
-- Sprint: SPRINT_20260219_008 (BE8-03)
|
||||
|
||||
-- ============================================================================
|
||||
-- Bundle catalog
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
CONSTRAINT uq_control_bundles_tenant_slug UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_name
|
||||
ON release.control_bundles (tenant_id, name, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_updated
|
||||
ON release.control_bundles (tenant_id, updated_at DESC, id);
|
||||
|
||||
COMMENT ON TABLE release.control_bundles IS
|
||||
'Release-control bundle identities scoped per tenant.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Immutable bundle versions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL CHECK (version_number > 0),
|
||||
digest TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'deprecated')),
|
||||
components_count INT NOT NULL DEFAULT 0,
|
||||
changelog TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
CONSTRAINT uq_control_bundle_versions_bundle_version UNIQUE (bundle_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_versions_tenant_bundle
|
||||
ON release.control_bundle_versions (tenant_id, bundle_id, version_number DESC, id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_versions_tenant_digest
|
||||
ON release.control_bundle_versions (tenant_id, digest);
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_versions IS
|
||||
'Immutable versions for release-control bundles.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Version components
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_components (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
bundle_version_id UUID NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
|
||||
component_version_id TEXT NOT NULL,
|
||||
component_name TEXT NOT NULL,
|
||||
image_digest TEXT NOT NULL,
|
||||
deploy_order INT NOT NULL DEFAULT 0,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_control_bundle_components_unique
|
||||
UNIQUE (bundle_version_id, component_version_id, deploy_order)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_components_tenant_version
|
||||
ON release.control_bundle_components (tenant_id, bundle_version_id, deploy_order, component_name, component_version_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_components_tenant_bundle
|
||||
ON release.control_bundle_components (tenant_id, bundle_id, bundle_version_id);
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_components IS
|
||||
'Component manifests attached to immutable release-control bundle versions.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Materialization runs
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.control_bundle_materialization_runs (
|
||||
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bundle_id UUID NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
bundle_version_id UUID NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
||||
target_environment TEXT,
|
||||
reason TEXT,
|
||||
requested_by TEXT NOT NULL DEFAULT 'system',
|
||||
idempotency_key TEXT,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_materialization_tenant_version
|
||||
ON release.control_bundle_materialization_runs (tenant_id, bundle_id, bundle_version_id, requested_at DESC, run_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_control_bundle_materialization_tenant_status
|
||||
ON release.control_bundle_materialization_runs (tenant_id, status, requested_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_control_bundle_materialization_idempotency
|
||||
ON release.control_bundle_materialization_runs (tenant_id, bundle_id, bundle_version_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE release.control_bundle_materialization_runs IS
|
||||
'Auditable materialization runs for release-control bundle versions.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Row level security
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE release.control_bundles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_components ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE release.control_bundle_materialization_runs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundles'
|
||||
AND policyname = 'control_bundles_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundles_tenant_isolation ON release.control_bundles
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_versions'
|
||||
AND policyname = 'control_bundle_versions_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_versions_tenant_isolation ON release.control_bundle_versions
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_components'
|
||||
AND policyname = 'control_bundle_components_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_components_tenant_isolation ON release.control_bundle_components
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'release'
|
||||
AND tablename = 'control_bundle_materialization_runs'
|
||||
AND policyname = 'control_bundle_materialization_runs_tenant_isolation') THEN
|
||||
CREATE POLICY control_bundle_materialization_runs_tenant_isolation ON release.control_bundle_materialization_runs
|
||||
FOR ALL
|
||||
USING (tenant_id = release_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = release_app.require_current_tenant());
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Update triggers
|
||||
-- ============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_control_bundles_updated_at ON release.control_bundles;
|
||||
CREATE TRIGGER trg_control_bundles_updated_at
|
||||
BEFORE UPDATE ON release.control_bundles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION release.update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_control_bundle_materialization_runs_updated_at ON release.control_bundle_materialization_runs;
|
||||
CREATE TRIGGER trg_control_bundle_materialization_runs_updated_at
|
||||
BEFORE UPDATE ON release.control_bundle_materialization_runs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION release.update_updated_at_column();
|
||||
@@ -0,0 +1,71 @@
|
||||
-- SPRINT_20260219_016 / PACK-ADM-02
|
||||
-- Administration A6 trust and signing owner mutation persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_keys (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
key_alias text NOT NULL,
|
||||
algorithm text NOT NULL,
|
||||
status text NOT NULL,
|
||||
current_version integer NOT NULL DEFAULT 1,
|
||||
metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_keys_tenant_alias
|
||||
ON release.trust_keys (tenant_id, lower(key_alias));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_keys_tenant_status
|
||||
ON release.trust_keys (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_issuers (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
issuer_name text NOT NULL,
|
||||
issuer_uri text NOT NULL,
|
||||
trust_level text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_issuers_tenant_uri
|
||||
ON release.trust_issuers (tenant_id, lower(issuer_uri));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_issuers_tenant_status
|
||||
ON release.trust_issuers (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_certificates (
|
||||
id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
key_id uuid NULL REFERENCES release.trust_keys(id) ON DELETE SET NULL,
|
||||
issuer_id uuid NULL REFERENCES release.trust_issuers(id) ON DELETE SET NULL,
|
||||
serial_number text NOT NULL,
|
||||
status text NOT NULL,
|
||||
not_before timestamptz NOT NULL,
|
||||
not_after timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_trust_certificates_tenant_serial
|
||||
ON release.trust_certificates (tenant_id, lower(serial_number));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_trust_certificates_tenant_status
|
||||
ON release.trust_certificates (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.trust_transparency_configs (
|
||||
tenant_id uuid PRIMARY KEY,
|
||||
log_url text NOT NULL,
|
||||
witness_url text NULL,
|
||||
enforce_inclusion boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL,
|
||||
updated_by text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public AdministrationTrustSigningMutationEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TrustSigningLifecycle_CreateRotateRevokeAndConfigure_Works()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var createKeyResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
new CreateAdministrationTrustKeyRequest(
|
||||
Alias: "core-signing-k1",
|
||||
Algorithm: "ed25519",
|
||||
MetadataJson: "{\"owner\":\"secops\"}"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createKeyResponse.StatusCode);
|
||||
var key = await createKeyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal("active", key!.Status);
|
||||
Assert.Equal(1, key.CurrentVersion);
|
||||
|
||||
var rotateResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/keys/{key.KeyId}/rotate",
|
||||
new RotateAdministrationTrustKeyRequest("scheduled_rotation", "CHG-100"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, rotateResponse.StatusCode);
|
||||
var rotatedKey = await rotateResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(rotatedKey);
|
||||
Assert.Equal(2, rotatedKey!.CurrentVersion);
|
||||
|
||||
var issuerResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/issuers",
|
||||
new RegisterAdministrationTrustIssuerRequest(
|
||||
Name: "Core Root CA",
|
||||
IssuerUri: "https://issuer.core.example/root",
|
||||
TrustLevel: "high"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, issuerResponse.StatusCode);
|
||||
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuer);
|
||||
|
||||
var certificateResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/certificates",
|
||||
new RegisterAdministrationTrustCertificateRequest(
|
||||
KeyId: key.KeyId,
|
||||
IssuerId: issuer!.IssuerId,
|
||||
SerialNumber: "SER-2026-0001",
|
||||
NotBefore: DateTimeOffset.Parse("2026-02-01T00:00:00Z"),
|
||||
NotAfter: DateTimeOffset.Parse("2027-02-01T00:00:00Z")),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, certificateResponse.StatusCode);
|
||||
var certificate = await certificateResponse.Content.ReadFromJsonAsync<AdministrationTrustCertificateSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(certificate);
|
||||
Assert.Equal("active", certificate!.Status);
|
||||
|
||||
var revokeCertificateResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/certificates/{certificate.CertificateId}/revoke",
|
||||
new RevokeAdministrationTrustCertificateRequest("scheduled_retirement", "IR-77"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, revokeCertificateResponse.StatusCode);
|
||||
var revokedCertificate = await revokeCertificateResponse.Content.ReadFromJsonAsync<AdministrationTrustCertificateSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(revokedCertificate);
|
||||
Assert.Equal("revoked", revokedCertificate!.Status);
|
||||
|
||||
var revokeKeyResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/keys/{key.KeyId}/revoke",
|
||||
new RevokeAdministrationTrustKeyRequest("post-rotation retirement", "CHG-101"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, revokeKeyResponse.StatusCode);
|
||||
var revokedKey = await revokeKeyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(revokedKey);
|
||||
Assert.Equal("revoked", revokedKey!.Status);
|
||||
|
||||
var configureResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/transparency-log",
|
||||
new ConfigureAdministrationTransparencyLogRequest(
|
||||
LogUrl: "https://rekor.core.example",
|
||||
WitnessUrl: "https://rekor-witness.core.example",
|
||||
EnforceInclusion: true),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, configureResponse.StatusCode);
|
||||
var transparencyConfig = await configureResponse.Content.ReadFromJsonAsync<AdministrationTransparencyLogConfig>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(transparencyConfig);
|
||||
Assert.Equal("https://rekor.core.example", transparencyConfig!.LogUrl);
|
||||
Assert.True(transparencyConfig.EnforceInclusion);
|
||||
|
||||
var keys = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustKeySummary>>(
|
||||
"/api/v1/administration/trust-signing/keys?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(keys);
|
||||
Assert.Single(keys!.Items);
|
||||
Assert.Equal("revoked", keys.Items[0].Status);
|
||||
|
||||
var issuers = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustIssuerSummary>>(
|
||||
"/api/v1/administration/trust-signing/issuers?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuers);
|
||||
Assert.Single(issuers!.Items);
|
||||
|
||||
var certificates = await client.GetFromJsonAsync<PlatformListResponse<AdministrationTrustCertificateSummary>>(
|
||||
"/api/v1/administration/trust-signing/certificates?limit=10&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(certificates);
|
||||
Assert.Single(certificates!.Items);
|
||||
Assert.Equal("revoked", certificates.Items[0].Status);
|
||||
|
||||
var transparency = await client.GetFromJsonAsync<PlatformItemResponse<AdministrationTransparencyLogConfig>>(
|
||||
"/api/v1/administration/trust-signing/transparency-log",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(transparency);
|
||||
Assert.Equal("https://rekor.core.example", transparency!.Item.LogUrl);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateTrustKey_WithDuplicateAlias_ReturnsConflict()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var request = new CreateAdministrationTrustKeyRequest("duplicate-key", "ed25519", null);
|
||||
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TrustSigningMutations_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TrustSigningMutationEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/rotate", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/certificates/{certificateId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "PUT", PlatformPolicies.TrustAdmin);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, routePattern, StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "trust-signing-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PackAdapterEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public PackAdapterEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DashboardSummary_IsDeterministicAndContainsPackFields()
|
||||
{
|
||||
using var client = CreateTenantClient($"tenant-dashboard-{Guid.NewGuid():N}");
|
||||
|
||||
var firstResponse = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
var secondResponse = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
|
||||
var first = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
var second = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
using var document = JsonDocument.Parse(first);
|
||||
var item = document.RootElement.GetProperty("item");
|
||||
|
||||
Assert.Equal("warning", item.GetProperty("dataConfidence").GetProperty("status").GetString());
|
||||
Assert.Equal(2, item.GetProperty("environmentsWithCriticalReachable").GetInt32());
|
||||
Assert.True(item.GetProperty("topDrivers").GetArrayLength() >= 1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PackAdapterRoutes_ReturnSuccessAndStableOrdering()
|
||||
{
|
||||
using var client = CreateTenantClient($"tenant-ops-{Guid.NewGuid():N}");
|
||||
var endpoints = new[]
|
||||
{
|
||||
"/api/v1/platform/data-integrity/summary",
|
||||
"/api/v1/platform/data-integrity/report",
|
||||
"/api/v1/platform/feeds/freshness",
|
||||
"/api/v1/platform/scan-pipeline/health",
|
||||
"/api/v1/platform/reachability/ingest-health",
|
||||
"/api/v1/administration/summary",
|
||||
"/api/v1/administration/identity-access",
|
||||
"/api/v1/administration/tenant-branding",
|
||||
"/api/v1/administration/notifications",
|
||||
"/api/v1/administration/usage-limits",
|
||||
"/api/v1/administration/policy-governance",
|
||||
"/api/v1/administration/trust-signing",
|
||||
"/api/v1/administration/system",
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
var feedsResponse = await client.GetStringAsync("/api/v1/platform/feeds/freshness", TestContext.Current.CancellationToken);
|
||||
using var feedsDocument = JsonDocument.Parse(feedsResponse);
|
||||
var sources = feedsDocument.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetProperty("source").GetString()!)
|
||||
.ToArray();
|
||||
var ordered = sources.OrderBy(source => source, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(ordered, sources);
|
||||
|
||||
var administrationSummary = await client.GetStringAsync("/api/v1/administration/summary", TestContext.Current.CancellationToken);
|
||||
using var administrationSummaryDocument = JsonDocument.Parse(administrationSummary);
|
||||
var actionPaths = administrationSummaryDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("domains")
|
||||
.EnumerateArray()
|
||||
.Select(domain => domain.GetProperty("actionPath").GetString()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("/administration/identity-access", actionPaths);
|
||||
Assert.Contains("/administration/tenant-branding", actionPaths);
|
||||
|
||||
var identityAccess = await client.GetStringAsync("/api/v1/administration/identity-access", TestContext.Current.CancellationToken);
|
||||
using var identityAccessDocument = JsonDocument.Parse(identityAccess);
|
||||
var tabs = identityAccessDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("tabs")
|
||||
.EnumerateArray()
|
||||
.Select(tab => tab.GetProperty("tabId").GetString()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("users", tabs);
|
||||
Assert.Contains("api-tokens", tabs);
|
||||
|
||||
var policyGovernance = await client.GetStringAsync("/api/v1/administration/policy-governance", TestContext.Current.CancellationToken);
|
||||
using var policyDocument = JsonDocument.Parse(policyGovernance);
|
||||
var aliases = policyDocument.RootElement
|
||||
.GetProperty("item")
|
||||
.GetProperty("legacyAliases")
|
||||
.EnumerateArray()
|
||||
.Select(alias => alias.GetProperty("legacyPath").GetString()!)
|
||||
.ToArray();
|
||||
var orderedAliases = aliases.OrderBy(path => path, StringComparer.Ordinal).ToArray();
|
||||
|
||||
Assert.Equal(orderedAliases, aliases);
|
||||
Assert.Contains("/policy/governance", aliases);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DashboardSummary_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/dashboard/summary", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TrustSigningEndpoint_RequiresTrustReadPolicy()
|
||||
{
|
||||
var dataSource = _factory.Services.GetRequiredService<EndpointDataSource>();
|
||||
var trustEndpoint = dataSource.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.Single(endpoint => string.Equals(endpoint.RoutePattern.RawText, "/api/v1/administration/trust-signing", StringComparison.Ordinal));
|
||||
|
||||
var policies = trustEndpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(PlatformPolicies.TrustRead, policies);
|
||||
Assert.DoesNotContain(PlatformPolicies.SetupRead, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "pack-adapter-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseControlEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ReleaseControlEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BundleLifecycle_CreateListPublishAndMaterialize_Works()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest("checkout-service", "Checkout Service", "primary checkout flow"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var list = await client.GetFromJsonAsync<PlatformListResponse<ReleaseControlBundleSummary>>(
|
||||
"/api/v1/release-control/bundles?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(list);
|
||||
Assert.Single(list!.Items);
|
||||
Assert.Equal(created!.Id, list.Items[0].Id);
|
||||
|
||||
var publishRequest = new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "initial release",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: "checkout@1.0.0",
|
||||
ComponentName: "checkout",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions",
|
||||
publishRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
Assert.Equal(1, version!.VersionNumber);
|
||||
Assert.StartsWith("sha256:", version.Digest, StringComparison.Ordinal);
|
||||
Assert.Single(version.Components);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions/{version.Id}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest("prod-eu", "promotion", "idem-001"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, materializeResponse.StatusCode);
|
||||
|
||||
var materialization = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(materialization);
|
||||
Assert.Equal("queued", materialization!.Status);
|
||||
|
||||
var secondMaterializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{created.Id}/versions/{version.Id}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest("prod-eu", "promotion", "idem-001"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, secondMaterializeResponse.StatusCode);
|
||||
|
||||
var duplicateMaterialization = await secondMaterializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(duplicateMaterialization);
|
||||
Assert.Equal(materialization.RunId, duplicateMaterialization!.RunId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateBundle_WithDuplicateSlug_ReturnsConflict()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var request = new CreateReleaseControlBundleRequest("payments", "Payments", null);
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
first.EnsureSuccessStatusCode();
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListBundles_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "release-control-test");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
|
||||
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
|
||||
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
|
||||
Reference in New Issue
Block a user