wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -19,7 +19,8 @@ public static class AdministrationTrustSigningMutationEndpoints
public static IEndpointRouteBuilder MapAdministrationTrustSigningMutationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/administration/trust-signing")
.WithTags("Administration");
.WithTags("Administration")
.RequireAuthorization(PlatformPolicies.TrustRead);
group.MapGet("/keys", async Task<IResult>(
HttpContext context,

View File

@@ -17,7 +17,8 @@ public static class AnalyticsEndpoints
public static IEndpointRouteBuilder MapAnalyticsEndpoints(this IEndpointRouteBuilder app)
{
var analytics = app.MapGroup("/api/analytics")
.WithTags("Analytics");
.WithTags("Analytics")
.RequireAuthorization(PlatformPolicies.AnalyticsRead);
analytics.MapGet("/suppliers", async Task<IResult> (
HttpContext context,

View File

@@ -15,7 +15,8 @@ public static class ContextEndpoints
public static IEndpointRouteBuilder MapContextEndpoints(this IEndpointRouteBuilder app)
{
var context = app.MapGroup("/api/v2/context")
.WithTags("Platform Context");
.WithTags("Platform Context")
.RequireAuthorization(PlatformPolicies.ContextRead);
context.MapGet("/regions", async Task<IResult>(
HttpContext httpContext,

View File

@@ -18,7 +18,8 @@ public static class EnvironmentSettingsAdminEndpoints
public static IEndpointRouteBuilder MapEnvironmentSettingsAdminEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/platform/envsettings/db")
.WithTags("Environment Settings Admin");
.WithTags("Environment Settings Admin")
.RequireAuthorization(PlatformPolicies.SetupRead);
group.MapGet("/", async (IEnvironmentSettingsStore store, CancellationToken ct) =>
{

View File

@@ -7,6 +7,7 @@ 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.Services;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Export;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Models;
@@ -31,13 +32,14 @@ public static class EvidenceThreadEndpoints
public static IEndpointRouteBuilder MapEvidenceThreadEndpoints(this IEndpointRouteBuilder app)
{
var evidence = app.MapGroup("/api/v1/evidence")
.WithTags("Evidence Thread");
.WithTags("Evidence Thread")
.RequireAuthorization(PlatformPolicies.ContextRead);
// GET /api/v1/evidence/{artifactDigest} - Get evidence thread for artifact
evidence.MapGet("/{artifactDigest}", GetEvidenceThread)
.WithName("GetEvidenceThread")
.WithSummary("Get evidence thread for an artifact")
.WithDescription("Retrieves the full evidence thread graph for an artifact by its digest.")
.WithDescription("Retrieves the full evidence thread graph for an artifact by its digest, including node count, link count, verdict, and risk score.")
.Produces<EvidenceThreadResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -46,7 +48,8 @@ public static class EvidenceThreadEndpoints
evidence.MapPost("/{artifactDigest}/export", ExportEvidenceThread)
.WithName("ExportEvidenceThread")
.WithSummary("Export evidence thread as DSSE bundle")
.WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification.")
.WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification. Supports DSSE, JSON, Markdown, and PDF formats. The envelope is optionally signed with the specified key.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.Produces<EvidenceExportResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -55,7 +58,8 @@ public static class EvidenceThreadEndpoints
evidence.MapPost("/{artifactDigest}/transcript", GenerateTranscript)
.WithName("GenerateEvidenceTranscript")
.WithSummary("Generate natural language transcript")
.WithDescription("Generates a natural language transcript explaining the evidence thread.")
.WithDescription("Generates a natural language transcript explaining the evidence thread in summary, detailed, or audit format. May invoke an LLM for rationale generation when enabled.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.Produces<EvidenceTranscriptResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -64,7 +68,7 @@ public static class EvidenceThreadEndpoints
evidence.MapGet("/{artifactDigest}/nodes", GetEvidenceNodes)
.WithName("GetEvidenceNodes")
.WithSummary("Get evidence nodes for an artifact")
.WithDescription("Retrieves all evidence nodes in the thread.")
.WithDescription("Retrieves all evidence nodes in the thread, optionally filtered by node kind (e.g., sbom, scan, attestation). Returns node summaries, confidence scores, and anchor counts.")
.Produces<EvidenceNodeListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -73,7 +77,7 @@ public static class EvidenceThreadEndpoints
evidence.MapGet("/{artifactDigest}/links", GetEvidenceLinks)
.WithName("GetEvidenceLinks")
.WithSummary("Get evidence links for an artifact")
.WithDescription("Retrieves all evidence links in the thread.")
.WithDescription("Retrieves all directed evidence links in the thread, describing provenance and dependency relationships between evidence nodes.")
.Produces<EvidenceLinkListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -82,7 +86,8 @@ public static class EvidenceThreadEndpoints
evidence.MapPost("/{artifactDigest}/collect", CollectEvidence)
.WithName("CollectEvidence")
.WithSummary("Collect evidence for an artifact")
.WithDescription("Triggers collection of all available evidence for an artifact.")
.WithDescription("Triggers collection of all available evidence for an artifact: SBOM diff, reachability graph, VEX advisories, and attestations. Returns the count of nodes and links created, plus any collection errors.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.Produces<EvidenceCollectionResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);

View File

@@ -20,7 +20,8 @@ public static class FederationTelemetryEndpoints
public static IEndpointRouteBuilder MapFederationTelemetryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/telemetry/federation")
.WithTags("Federated Telemetry");
.WithTags("Federated Telemetry")
.RequireAuthorization(PlatformPolicies.FederationRead);
// GET /consent — get consent state
group.MapGet("/consent", async Task<IResult>(

View File

@@ -24,7 +24,8 @@ public static class FunctionMapEndpoints
public static IEndpointRouteBuilder MapFunctionMapEndpoints(this IEndpointRouteBuilder app)
{
var maps = app.MapGroup("/api/v1/function-maps")
.WithTags("Function Maps");
.WithTags("Function Maps")
.RequireAuthorization(PlatformPolicies.FunctionMapRead);
MapCrudEndpoints(maps);
MapVerifyEndpoints(maps);

View File

@@ -15,7 +15,8 @@ public static class IntegrationReadModelEndpoints
public static IEndpointRouteBuilder MapIntegrationReadModelEndpoints(this IEndpointRouteBuilder app)
{
var integrations = app.MapGroup("/api/v2/integrations")
.WithTags("Integrations V2");
.WithTags("Integrations V2")
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
integrations.MapGet("/feeds", async Task<IResult>(
HttpContext context,

View File

@@ -16,7 +16,8 @@ public static class LegacyAliasEndpoints
public static IEndpointRouteBuilder MapLegacyAliasEndpoints(this IEndpointRouteBuilder app)
{
var legacy = app.MapGroup("/api/v1")
.WithTags("Pack22 Legacy Aliases");
.WithTags("Pack22 Legacy Aliases")
.RequireAuthorization(PlatformPolicies.ContextRead);
legacy.MapGet("/context/regions", async Task<IResult>(
HttpContext context,

View File

@@ -43,7 +43,8 @@ public static class PackAdapterEndpoints
.RequireAuthorization(PlatformPolicies.HealthRead);
var platform = app.MapGroup("/api/v1/platform")
.WithTags("Platform Ops");
.WithTags("Platform Ops")
.RequireAuthorization(PlatformPolicies.HealthRead);
platform.MapGet("/data-integrity/summary", (
HttpContext context,

View File

@@ -18,7 +18,8 @@ public static class PlatformEndpoints
public static IEndpointRouteBuilder MapPlatformEndpoints(this IEndpointRouteBuilder app)
{
var platform = app.MapGroup("/api/v1/platform")
.WithTags("Platform");
.WithTags("Platform")
.RequireAuthorization(PlatformPolicies.HealthRead);
MapHealthEndpoints(platform);
MapQuotaEndpoints(platform);
@@ -161,12 +162,12 @@ public static class PlatformEndpoints
return failure!;
}
if (string.IsNullOrWhiteSpace(tenantId))
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
{
return Results.BadRequest(new { error = "tenant_missing" });
return tenantFailure!;
}
var result = await service.GetTenantAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
var result = await service.GetTenantAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
requestContext!.TenantId,
requestContext.ActorId,
@@ -293,12 +294,12 @@ public static class PlatformEndpoints
return failure!;
}
if (string.IsNullOrWhiteSpace(tenantId))
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
{
return Results.BadRequest(new { error = "tenant_missing" });
return tenantFailure!;
}
var status = await service.GetTenantSetupStatusAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
var status = await service.GetTenantSetupStatusAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(status);
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
}
@@ -476,7 +477,8 @@ public static class PlatformEndpoints
private static void MapLegacyQuotaCompatibilityEndpoints(IEndpointRouteBuilder app)
{
var quotas = app.MapGroup("/api/v1/authority/quotas")
.WithTags("Platform Quotas Compatibility");
.WithTags("Platform Quotas Compatibility")
.RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet(string.Empty, async Task<IResult> (
HttpContext context,
@@ -491,7 +493,7 @@ public static class PlatformEndpoints
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(BuildLegacyEntitlement(summary.Value, requestContext!));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/consumption", async Task<IResult> (
HttpContext context,
@@ -506,7 +508,7 @@ public static class PlatformEndpoints
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(BuildLegacyConsumption(summary.Value));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/dashboard", async Task<IResult> (
HttpContext context,
@@ -528,7 +530,7 @@ public static class PlatformEndpoints
activeAlerts = 0,
recentViolations = 0
});
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/history", async Task<IResult> (
HttpContext context,
@@ -570,7 +572,7 @@ public static class PlatformEndpoints
points,
aggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation
});
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/tenants", async Task<IResult> (
HttpContext context,
@@ -612,7 +614,7 @@ public static class PlatformEndpoints
.ToArray();
return Results.Ok(new { items, total = 1 });
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
HttpContext context,
@@ -626,7 +628,12 @@ public static class PlatformEndpoints
return failure!;
}
var result = await service.GetTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
{
return tenantFailure!;
}
var result = await service.GetTenantAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
var consumption = BuildLegacyConsumption(result.Value);
return Results.Ok(new
@@ -655,7 +662,7 @@ public static class PlatformEndpoints
},
forecast = BuildLegacyForecast("api")
});
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/forecast", async Task<IResult> (
HttpContext context,
@@ -673,7 +680,7 @@ public static class PlatformEndpoints
var forecasts = categories.Select(BuildLegacyForecast).ToArray();
return Results.Ok(forecasts);
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/alerts", (HttpContext context, PlatformRequestContextResolver resolver) =>
{
@@ -694,7 +701,7 @@ public static class PlatformEndpoints
channels = Array.Empty<object>(),
escalationMinutes = 30
}));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapPost("/alerts", (HttpContext context, PlatformRequestContextResolver resolver, [FromBody] object config) =>
{
@@ -704,10 +711,11 @@ public static class PlatformEndpoints
}
return Task.FromResult<IResult>(Results.Ok(config));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
var rateLimits = app.MapGroup("/api/v1/gateway/rate-limits")
.WithTags("Platform Gateway Compatibility");
.WithTags("Platform Gateway Compatibility")
.RequireAuthorization(PlatformPolicies.QuotaRead);
rateLimits.MapGet(string.Empty, (HttpContext context, PlatformRequestContextResolver resolver) =>
{
@@ -729,7 +737,7 @@ public static class PlatformEndpoints
burstRemaining = 119
}
}));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
rateLimits.MapGet("/violations", (HttpContext context, PlatformRequestContextResolver resolver) =>
{
@@ -749,7 +757,7 @@ public static class PlatformEndpoints
end = now.ToString("o")
}
}));
}).RequireAuthorization();
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}
private static LegacyQuotaItem[] BuildLegacyConsumption(IReadOnlyList<PlatformQuotaUsage> usage)
@@ -885,6 +893,37 @@ public static class PlatformEndpoints
return false;
}
private static bool TryResolveRequestedTenant(
PlatformRequestContext requestContext,
string? requestedTenantId,
out string normalizedTenantId,
out IResult? failure)
{
normalizedTenantId = string.Empty;
if (string.IsNullOrWhiteSpace(requestedTenantId))
{
failure = Results.BadRequest(new { error = "tenant_missing" });
return false;
}
normalizedTenantId = requestedTenantId.Trim().ToLowerInvariant();
if (!string.Equals(normalizedTenantId, requestContext.TenantId, StringComparison.Ordinal))
{
failure = Results.Json(
new
{
error = "tenant_forbidden",
requestedTenantId = normalizedTenantId
},
statusCode: StatusCodes.Status403Forbidden);
return false;
}
failure = null;
return true;
}
private sealed record LegacyQuotaItem(
string Category,
decimal Current,

View File

@@ -25,7 +25,8 @@ public static class PolicyInteropEndpoints
public static IEndpointRouteBuilder MapPolicyInteropEndpoints(this IEndpointRouteBuilder app)
{
var interop = app.MapGroup("/api/v1/policy/interop")
.WithTags("PolicyInterop");
.WithTags("PolicyInterop")
.RequireAuthorization(PlatformPolicies.PolicyRead);
MapExportEndpoint(interop);
MapImportEndpoint(interop);

View File

@@ -19,7 +19,8 @@ public static class ReleaseControlEndpoints
public static IEndpointRouteBuilder MapReleaseControlEndpoints(this IEndpointRouteBuilder app)
{
var bundles = app.MapGroup("/api/v1/release-control/bundles")
.WithTags("Release Control");
.WithTags("Release Control")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
bundles.MapGet(string.Empty, async Task<IResult>(
HttpContext context,

View File

@@ -16,7 +16,8 @@ public static class ReleaseReadModelEndpoints
public static IEndpointRouteBuilder MapReleaseReadModelEndpoints(this IEndpointRouteBuilder app)
{
var releases = app.MapGroup("/api/v2/releases")
.WithTags("Releases V2");
.WithTags("Releases V2")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
releases.MapGet(string.Empty, async Task<IResult>(
HttpContext context,

View File

@@ -25,7 +25,8 @@ public static class ScoreEndpoints
public static IEndpointRouteBuilder MapScoreEndpoints(this IEndpointRouteBuilder app)
{
var score = app.MapGroup("/api/v1/score")
.WithTags("Score");
.WithTags("Score")
.RequireAuthorization(PlatformPolicies.ScoreRead);
MapEvaluateEndpoints(score);
MapHistoryEndpoints(score);

View File

@@ -15,7 +15,8 @@ public static class SecurityReadModelEndpoints
public static IEndpointRouteBuilder MapSecurityReadModelEndpoints(this IEndpointRouteBuilder app)
{
var security = app.MapGroup("/api/v2/security")
.WithTags("Security V2");
.WithTags("Security V2")
.RequireAuthorization(PlatformPolicies.SecurityRead);
security.MapGet("/findings", async Task<IResult>(
HttpContext context,

View File

@@ -15,7 +15,8 @@ public static class TopologyReadModelEndpoints
public static IEndpointRouteBuilder MapTopologyReadModelEndpoints(this IEndpointRouteBuilder app)
{
var topology = app.MapGroup("/api/v2/topology")
.WithTags("Topology V2");
.WithTags("Topology V2")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/regions", async Task<IResult>(
HttpContext context,

View File

@@ -1,5 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Platform.Database.Postgres;
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Concurrent;
@@ -249,26 +251,9 @@ public sealed class InMemoryPlatformContextStore : IPlatformContextStore
public sealed class PostgresPlatformContextStore : IPlatformContextStore
{
private const string SelectRegionsSql = """
SELECT region_id, display_name, sort_order, enabled
FROM platform.context_regions
WHERE enabled = true
ORDER BY sort_order, region_id
""";
private const string SelectEnvironmentsSql = """
SELECT environment_id, region_id, environment_type, display_name, sort_order, enabled
FROM platform.context_environments
WHERE enabled = true
ORDER BY sort_order, region_id, environment_id
""";
private const string SelectPreferencesSql = """
SELECT regions, environments, time_window, updated_at, updated_by
FROM platform.ui_context_preferences
WHERE tenant_id = @tenant_id AND actor_id = @actor_id
""";
private const int DefaultCommandTimeoutSeconds = 30;
// PostgreSQL-specific upsert with RETURNING for preferences.
private const string UpsertPreferencesSql = """
INSERT INTO platform.ui_context_preferences
(tenant_id, actor_id, regions, environments, time_window, updated_at, updated_by)
@@ -293,40 +278,51 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
public async Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default)
{
var regions = new List<PlatformContextRegion>();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectRegionsSql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
regions.Add(new PlatformContextRegion(
reader.GetString(0),
reader.GetString(1),
reader.GetInt32(2),
reader.GetBoolean(3)));
}
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
return regions;
var entities = await dbContext.ContextRegions
.AsNoTracking()
.Where(r => r.Enabled)
.OrderBy(r => r.SortOrder)
.ThenBy(r => r.RegionId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities
.Select(r => new PlatformContextRegion(
r.RegionId,
r.DisplayName,
r.SortOrder,
r.Enabled))
.ToArray();
}
public async Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default)
{
var environments = new List<PlatformContextEnvironment>();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectEnvironmentsSql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
environments.Add(new PlatformContextEnvironment(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetInt32(4),
reader.GetBoolean(5)));
}
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
return environments;
var entities = await dbContext.ContextEnvironments
.AsNoTracking()
.Where(e => e.Enabled)
.OrderBy(e => e.SortOrder)
.ThenBy(e => e.RegionId)
.ThenBy(e => e.EnvironmentId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities
.Select(e => new PlatformContextEnvironment(
e.EnvironmentId,
e.RegionId,
e.EnvironmentType,
e.DisplayName,
e.SortOrder,
e.Enabled))
.ToArray();
}
public async Task<PlatformContextPreferences?> GetPreferencesAsync(
@@ -335,12 +331,15 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectPreferencesSql, connection);
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("actor_id", actorId);
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entity = await dbContext.UiContextPreferences
.AsNoTracking()
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.ActorId == actorId, cancellationToken)
.ConfigureAwait(false);
if (entity is null)
{
return null;
}
@@ -348,17 +347,18 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
return new PlatformContextPreferences(
tenantId,
actorId,
ReadTextArray(reader, 0),
ReadTextArray(reader, 1),
reader.GetString(2),
reader.GetFieldValue<DateTimeOffset>(3),
reader.GetString(4));
NormalizeTextArray(entity.Regions),
NormalizeTextArray(entity.Environments),
entity.TimeWindow,
new DateTimeOffset(DateTime.SpecifyKind(entity.UpdatedAt, DateTimeKind.Utc)),
entity.UpdatedBy);
}
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
PlatformContextPreferences preference,
CancellationToken cancellationToken = default)
{
// Keep raw SQL for PostgreSQL-specific ON CONFLICT ... RETURNING upsert.
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(UpsertPreferencesSql, connection);
command.Parameters.AddWithValue("tenant_id", preference.TenantId);
@@ -382,6 +382,21 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
reader.GetString(4));
}
private static string[] NormalizeTextArray(string[]? values)
{
if (values is null || values.Length == 0)
{
return Array.Empty<string>();
}
return values
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
}
private static string[] ReadTextArray(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Platform.Database;
@@ -19,7 +20,7 @@ internal sealed class PlatformMigrationAdminService
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public Task<MigrationResult> RunAsync(
public async Task<MigrationResult> RunAsync(
MigrationModuleInfo module,
MigrationCategory? category,
bool dryRun,
@@ -27,7 +28,20 @@ internal sealed class PlatformMigrationAdminService
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
var runner = CreateRunner(module, connectionString);
var appliedMigrations = await runner
.GetAppliedMigrationInfoAsync(cancellationToken)
.ConfigureAwait(false);
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
var consolidatedApplied = hasConsolidatedApplied
? appliedMigrations.First(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
: (MigrationInfo?)null;
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
var options = new MigrationRunOptions
{
@@ -38,7 +52,40 @@ internal sealed class PlatformMigrationAdminService
FailOnChecksumMismatch = true
};
return runner.RunFromAssemblyAsync(module.MigrationsAssembly, module.ResourcePrefix, options, cancellationToken);
if (appliedMigrations.Count == 0)
{
var result = await RunConsolidatedAsync(
module,
connectionString,
consolidatedArtifact,
options,
cancellationToken)
.ConfigureAwait(false);
if (result.Success && !options.DryRun)
{
await BackfillLegacyHistoryAsync(
module,
connectionString,
consolidatedArtifact.SourceMigrations,
cancellationToken)
.ConfigureAwait(false);
}
return result;
}
if (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0 && !options.DryRun)
{
await BackfillLegacyHistoryAsync(
module,
connectionString,
missingLegacyMigrations,
cancellationToken)
.ConfigureAwait(false);
}
return await RunAcrossSourcesAsync(module, connectionString, options, cancellationToken).ConfigureAwait(false);
}
public async Task<MigrationStatus> GetStatusAsync(
@@ -46,29 +93,296 @@ internal sealed class PlatformMigrationAdminService
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
var runner = CreateRunner(module, connectionString);
var appliedMigrations = await runner
.GetAppliedMigrationInfoAsync(cancellationToken)
.ConfigureAwait(false);
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
var consolidatedApplied = hasConsolidatedApplied
? appliedMigrations.First(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
: (MigrationInfo?)null;
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
if (appliedMigrations.Count == 0 || (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0))
{
return BuildConsolidatedStatus(module, consolidatedArtifact, appliedMigrations, consolidatedApplied);
}
var logger = _loggerFactory.CreateLogger($"platform.migrationstatus.{module.Name}");
var sources = module.Sources
.Select(static source => new MigrationAssemblySource(source.MigrationsAssembly, source.ResourcePrefix))
.ToArray();
var statusService = new MigrationStatusService(
connectionString,
module.SchemaName,
module.Name,
module.MigrationsAssembly,
sources,
logger);
return await statusService.GetStatusAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<string>> VerifyAsync(
public async Task<IReadOnlyList<string>> VerifyAsync(
MigrationModuleInfo module,
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
var runner = CreateRunner(module, connectionString);
return runner.ValidateChecksumsAsync(module.MigrationsAssembly, module.ResourcePrefix, cancellationToken);
var appliedMigrations = await runner
.GetAppliedMigrationInfoAsync(cancellationToken)
.ConfigureAwait(false);
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
var consolidatedApplied = hasConsolidatedApplied
? appliedMigrations.First(migration =>
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
: (MigrationInfo?)null;
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
if (appliedMigrations.Count > 0 && hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0)
{
return ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value);
}
var errors = new HashSet<string>(StringComparer.Ordinal);
if (hasConsolidatedApplied)
{
foreach (var error in ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value))
{
errors.Add(error);
}
}
foreach (var source in module.Sources)
{
var sourceErrors = await runner
.ValidateChecksumsAsync(source.MigrationsAssembly, source.ResourcePrefix, cancellationToken)
.ConfigureAwait(false);
foreach (var error in sourceErrors)
{
errors.Add(error);
}
}
return errors.OrderBy(static error => error, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<string> ValidateConsolidatedChecksum(
MigrationModuleConsolidatedArtifact artifact,
MigrationInfo appliedMigration)
{
if (string.Equals(appliedMigration.Checksum, artifact.Checksum, StringComparison.Ordinal))
{
return [];
}
return
[
$"Checksum mismatch for '{artifact.MigrationName}': expected '{artifact.Checksum[..16]}...', found '{appliedMigration.Checksum[..16]}...'"
];
}
private async Task<MigrationResult> RunConsolidatedAsync(
MigrationModuleInfo module,
string connectionString,
MigrationModuleConsolidatedArtifact consolidatedArtifact,
MigrationRunOptions options,
CancellationToken cancellationToken)
{
var tempRoot = Path.Combine(
Path.GetTempPath(),
"stellaops-migrations",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var migrationPath = Path.Combine(tempRoot, consolidatedArtifact.MigrationName);
await File.WriteAllTextAsync(migrationPath, consolidatedArtifact.Script, cancellationToken).ConfigureAwait(false);
try
{
var runner = CreateRunner(module, connectionString);
return await runner.RunAsync(tempRoot, options, cancellationToken).ConfigureAwait(false);
}
finally
{
TryDeleteDirectory(tempRoot);
}
}
private async Task BackfillLegacyHistoryAsync(
MigrationModuleInfo module,
string connectionString,
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> migrationsToBackfill,
CancellationToken cancellationToken)
{
if (migrationsToBackfill.Count == 0)
{
return;
}
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var schemaName = QuoteIdentifier(module.SchemaName);
var sql = $"""
INSERT INTO {schemaName}.schema_migrations (migration_name, category, checksum, applied_by, duration_ms)
VALUES (@name, @category, @checksum, @appliedBy, @durationMs)
ON CONFLICT (migration_name) DO NOTHING;
""";
await using var command = new NpgsqlCommand(sql, connection);
var nameParam = command.Parameters.Add("name", NpgsqlTypes.NpgsqlDbType.Text);
var categoryParam = command.Parameters.Add("category", NpgsqlTypes.NpgsqlDbType.Text);
var checksumParam = command.Parameters.Add("checksum", NpgsqlTypes.NpgsqlDbType.Text);
var appliedByParam = command.Parameters.Add("appliedBy", NpgsqlTypes.NpgsqlDbType.Text);
var durationParam = command.Parameters.Add("durationMs", NpgsqlTypes.NpgsqlDbType.Integer);
foreach (var migration in migrationsToBackfill)
{
nameParam.Value = migration.Name;
categoryParam.Value = migration.Category.ToString().ToLowerInvariant();
checksumParam.Value = migration.Checksum;
appliedByParam.Value = Environment.MachineName;
durationParam.Value = 0;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static MigrationStatus BuildConsolidatedStatus(
MigrationModuleInfo module,
MigrationModuleConsolidatedArtifact consolidatedArtifact,
IReadOnlyList<MigrationInfo> appliedMigrations,
MigrationInfo? consolidatedApplied)
{
var pending = consolidatedApplied is null
? new[] { new PendingMigrationInfo(consolidatedArtifact.MigrationName, MigrationCategory.Release) }
: [];
var checksumErrors = consolidatedApplied is null
? []
: ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied.Value);
var lastApplied = appliedMigrations
.OrderByDescending(static migration => migration.AppliedAt)
.FirstOrDefault();
return new MigrationStatus
{
ModuleName = module.Name,
SchemaName = module.SchemaName,
AppliedCount = appliedMigrations.Count,
PendingStartupCount = 0,
PendingReleaseCount = pending.Length,
LastAppliedMigration = lastApplied.Name,
LastAppliedAt = lastApplied.Name is null ? null : lastApplied.AppliedAt,
PendingMigrations = pending,
ChecksumErrors = checksumErrors
};
}
private async Task<MigrationResult> RunAcrossSourcesAsync(
MigrationModuleInfo module,
string connectionString,
MigrationRunOptions options,
CancellationToken cancellationToken)
{
var results = new List<MigrationResult>(module.Sources.Count);
foreach (var source in module.Sources)
{
var runner = CreateRunner(module, connectionString);
var result = await runner
.RunFromAssemblyAsync(source.MigrationsAssembly, source.ResourcePrefix, options, cancellationToken)
.ConfigureAwait(false);
results.Add(result);
if (!result.Success)
{
break;
}
}
return AggregateRunResults(results);
}
private static MigrationResult AggregateRunResults(IReadOnlyList<MigrationResult> results)
{
if (results.Count == 0)
{
return MigrationResult.Successful(0, 0, 0, 0, []);
}
if (results.Count == 1)
{
return results[0];
}
var firstFailure = results.FirstOrDefault(static result => !result.Success);
return new MigrationResult
{
Success = firstFailure is null,
AppliedCount = results.Sum(static result => result.AppliedCount),
SkippedCount = results.Max(static result => result.SkippedCount),
FilteredCount = results.Sum(static result => result.FilteredCount),
DurationMs = results.Sum(static result => result.DurationMs),
AppliedMigrations = results.SelectMany(static result => result.AppliedMigrations).ToArray(),
ChecksumErrors = results
.SelectMany(static result => result.ChecksumErrors)
.Distinct(StringComparer.Ordinal)
.OrderBy(static error => error, StringComparer.Ordinal)
.ToArray(),
ErrorMessage = firstFailure?.ErrorMessage
};
}
private MigrationRunner CreateRunner(MigrationModuleInfo module, string connectionString) =>
new(connectionString, module.SchemaName, module.Name, _loggerFactory.CreateLogger($"platform.migration.{module.Name}"));
private static string QuoteIdentifier(string identifier)
{
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);
return $"\"{escaped}\"";
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
private static IReadOnlyList<MigrationModuleConsolidatedSourceMigration> GetMissingLegacyMigrations(
MigrationModuleConsolidatedArtifact consolidatedArtifact,
IReadOnlyList<MigrationInfo> appliedMigrations)
{
var appliedNames = appliedMigrations
.Select(static migration => migration.Name)
.ToHashSet(StringComparer.Ordinal);
return consolidatedArtifact.SourceMigrations
.Where(migration => !appliedNames.Contains(migration.Name))
.ToArray();
}
private static bool IsConsolidatedInSync(
MigrationModuleConsolidatedArtifact consolidatedArtifact,
MigrationInfo? consolidatedApplied) =>
consolidatedApplied is not null &&
string.Equals(consolidatedApplied.Value.Checksum, consolidatedArtifact.Checksum, StringComparison.Ordinal);
private string ResolveConnectionString(MigrationModuleInfo module)
{
var envCandidates = new[]

View File

@@ -7,6 +7,7 @@ namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformRequestContextResolver
{
private const string LegacyTenantClaim = "tid";
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string ProjectHeader = "X-Stella-Project";
private const string ActorHeader = "X-StellaOps-Actor";
@@ -16,9 +17,8 @@ public sealed class PlatformRequestContextResolver
requestContext = null;
error = null;
if (!TryResolveTenant(context, out var tenantId))
if (!TryResolveTenant(context, out var tenantId, out error))
{
error = "tenant_missing";
return false;
}
@@ -29,29 +29,46 @@ public sealed class PlatformRequestContextResolver
return true;
}
private static bool TryResolveTenant(HttpContext context, out string tenantId)
private static bool TryResolveTenant(HttpContext context, out string tenantId, out string? error)
{
tenantId = string.Empty;
error = null;
var claimTenant = context.User.FindFirstValue(StellaOpsClaimTypes.Tenant);
var claimTenant = NormalizeTenant(
context.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? context.User.FindFirstValue(LegacyTenantClaim));
var canonicalHeaderTenant = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
var legacyHeaderTenant = ReadTenantHeader(context, LegacyTenantHeader);
if (!string.IsNullOrWhiteSpace(canonicalHeaderTenant) &&
!string.IsNullOrWhiteSpace(legacyHeaderTenant) &&
!string.Equals(canonicalHeaderTenant, legacyHeaderTenant, StringComparison.Ordinal))
{
error = "tenant_conflict";
return false;
}
var headerTenant = canonicalHeaderTenant ?? legacyHeaderTenant;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenantId = claimTenant.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(headerTenant) &&
!string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
{
error = "tenant_conflict";
return false;
}
tenantId = claimTenant;
return true;
}
if (TryResolveHeader(context, StellaOpsHttpHeaderNames.Tenant, out tenantId))
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenantId = tenantId.ToLowerInvariant();
return true;
}
if (TryResolveHeader(context, LegacyTenantHeader, out tenantId))
{
tenantId = tenantId.ToLowerInvariant();
tenantId = headerTenant;
return true;
}
error = "tenant_missing";
return false;
}
@@ -110,4 +127,18 @@ public sealed class PlatformRequestContextResolver
value = raw.Trim();
return true;
}
private static string? ReadTenantHeader(HttpContext context, string headerName)
{
return TryResolveHeader(context, headerName, out var value)
? NormalizeTenant(value)
: null;
}
private static string? NormalizeTenant(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Trim().ToLowerInvariant();
}
}

View File

@@ -1,8 +1,11 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Platform.Database.EfCore.Context;
using StellaOps.Platform.Database.Postgres;
namespace StellaOps.Platform.WebService.Services;
@@ -10,6 +13,7 @@ namespace StellaOps.Platform.WebService.Services;
/// PostgreSQL implementation of <see cref="IEnvironmentSettingsStore"/>.
/// Reads from <c>platform.environment_settings</c> with an in-memory cache
/// that is invalidated periodically by <see cref="EnvironmentSettingsRefreshService"/>.
/// Uses EF Core for read operations and raw SQL for PostgreSQL-specific upsert.
/// </summary>
public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
{
@@ -18,22 +22,12 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
private volatile IReadOnlyDictionary<string, string>? _cache;
private readonly object _cacheLock = new();
private const string SelectAllSql = """
SELECT key, value FROM platform.environment_settings ORDER BY key
""";
private const string SelectOneSql = """
SELECT value FROM platform.environment_settings WHERE key = @key
""";
private const int DefaultCommandTimeoutSeconds = 30;
private const string UpsertSql = """
INSERT INTO platform.environment_settings (key, value, updated_at, updated_by)
VALUES (@key, @value, now(), @updated_by)
ON CONFLICT (key) DO UPDATE SET value = @value, updated_at = now(), updated_by = @updated_by
""";
private const string DeleteSql = """
DELETE FROM platform.environment_settings WHERE key = @key
VALUES ({0}, {1}, now(), {2})
ON CONFLICT (key) DO UPDATE SET value = {1}, updated_at = now(), updated_by = {2}
""";
public PostgresEnvironmentSettingsStore(
@@ -52,20 +46,25 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
ct.ThrowIfCancellationRequested();
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectAllSql, conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
var entities = await dbContext.EnvironmentSettings
.AsNoTracking()
.OrderBy(e => e.Key)
.ToListAsync(ct)
.ConfigureAwait(false);
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entity in entities)
{
dict[reader.GetString(0)] = reader.GetString(1);
dict[entity.Key] = entity.Value;
}
var result = dict;
lock (_cacheLock)
{
_cache ??= result;
_cache ??= dict;
}
return _cache;
@@ -86,13 +85,16 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(value);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(UpsertSql, conn);
cmd.Parameters.AddWithValue("key", key);
cmd.Parameters.AddWithValue("value", value);
cmd.Parameters.AddWithValue("updated_by", updatedBy);
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
// Use raw SQL for PostgreSQL-specific ON CONFLICT upsert with server-side now().
await dbContext.Database.ExecuteSqlRawAsync(
UpsertSql,
[key, value, updatedBy],
ct).ConfigureAwait(false);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
InvalidateCache();
_logger.LogInformation("Environment setting {Key} updated by {UpdatedBy}", key, updatedBy);
@@ -103,14 +105,26 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(key);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(DeleteSql, conn);
cmd.Parameters.AddWithValue("key", key);
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = PlatformDbContextFactory.Create(
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
InvalidateCache();
var entity = await dbContext.EnvironmentSettings
.FirstOrDefaultAsync(e => e.Key == key, ct)
.ConfigureAwait(false);
_logger.LogInformation("Environment setting {Key} deleted ({Rows} rows affected)", key, rows);
if (entity is not null)
{
dbContext.EnvironmentSettings.Remove(entity);
var rows = await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
InvalidateCache();
_logger.LogInformation("Environment setting {Key} deleted ({Rows} rows affected)", key, rows);
}
else
{
_logger.LogInformation("Environment setting {Key} not found for deletion", key);
}
}
public void InvalidateCache()

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,6 +6,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-MGC-12 | DONE | Added `/api/v1/admin/migrations/{modules,status,verify,run}` endpoints with `platform.setup.admin` authorization and server-side migration execution wired to the platform-owned registry in `StellaOps.Platform.Database`. |
| SPRINT_20260222_051-MGC-12-SOURCES | DONE | Platform migration admin service now executes and verifies migrations across per-service plugin source sets, applies synthesized per-plugin consolidated migration on empty history with legacy history backfill, and auto-heals partial backfill states before per-source execution. |
| SPRINT_20260221_043-PLATFORM-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: fix seed endpoint authorization policy wiring and return structured non-500 error responses for expected failures. |
| 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`. |
@@ -34,5 +35,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| TASK-030-013 | BLOCKED | Attestation coverage view delivered; validation blocked pending ingestion datasets. |
| TASK-030-017 | BLOCKED | Stored procedures delivered; validation blocked pending ingestion datasets. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| PLATFORM-EF-03-WS | DONE | Sprint `docs/implplan/SPRINT_20260222_096_Platform_dal_to_efcore.md`: converted `PostgresEnvironmentSettingsStore` and `PostgresPlatformContextStore` to EF Core LINQ reads with `AsNoTracking()`, raw SQL upserts. Added `Microsoft.EntityFrameworkCore` package reference. |

View File

@@ -0,0 +1,90 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Platform.Database.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
internal partial class ContextEnvironmentEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Platform.Database.EfCore.Models.ContextEnvironment",
typeof(ContextEnvironment),
baseEntityType);
var environmentId = runtimeEntityType.AddProperty(
"EnvironmentId",
typeof(string),
propertyInfo: typeof(ContextEnvironment).GetProperty("EnvironmentId",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
environmentId.AddAnnotation("Relational:ColumnName", "environment_id");
var regionId = runtimeEntityType.AddProperty(
"RegionId",
typeof(string),
propertyInfo: typeof(ContextEnvironment).GetProperty("RegionId",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
regionId.AddAnnotation("Relational:ColumnName", "region_id");
var environmentType = runtimeEntityType.AddProperty(
"EnvironmentType",
typeof(string),
propertyInfo: typeof(ContextEnvironment).GetProperty("EnvironmentType",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
environmentType.AddAnnotation("Relational:ColumnName", "environment_type");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(ContextEnvironment).GetProperty("DisplayName",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var sortOrder = runtimeEntityType.AddProperty(
"SortOrder",
typeof(int),
propertyInfo: typeof(ContextEnvironment).GetProperty("SortOrder",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
sortOrder.AddAnnotation("Relational:ColumnName", "sort_order");
var enabled = runtimeEntityType.AddProperty(
"Enabled",
typeof(bool),
propertyInfo: typeof(ContextEnvironment).GetProperty("Enabled",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
enabled.AddAnnotation("Relational:ColumnName", "enabled");
enabled.AddAnnotation("Relational:DefaultValue", true);
var pk = runtimeEntityType.AddKey(new[] { environmentId });
pk.AddAnnotation("Relational:Name", "context_environments_pkey");
runtimeEntityType.SetPrimaryKey(pk);
runtimeEntityType.AddIndex(new[] { regionId, sortOrder, environmentId },
"ix_platform_context_environments_region_sort");
runtimeEntityType.AddIndex(new[] { sortOrder, regionId, environmentId },
"ix_platform_context_environments_sort");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
runtimeEntityType.AddAnnotation("Relational:TableName", "context_environments");
}
}
}

View File

@@ -0,0 +1,72 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Platform.Database.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
internal partial class ContextRegionEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Platform.Database.EfCore.Models.ContextRegion",
typeof(ContextRegion),
baseEntityType);
var regionId = runtimeEntityType.AddProperty(
"RegionId",
typeof(string),
propertyInfo: typeof(ContextRegion).GetProperty("RegionId",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
regionId.AddAnnotation("Relational:ColumnName", "region_id");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(ContextRegion).GetProperty("DisplayName",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var sortOrder = runtimeEntityType.AddProperty(
"SortOrder",
typeof(int),
propertyInfo: typeof(ContextRegion).GetProperty("SortOrder",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
sortOrder.AddAnnotation("Relational:ColumnName", "sort_order");
var enabled = runtimeEntityType.AddProperty(
"Enabled",
typeof(bool),
propertyInfo: typeof(ContextRegion).GetProperty("Enabled",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
enabled.AddAnnotation("Relational:ColumnName", "enabled");
enabled.AddAnnotation("Relational:DefaultValue", true);
var pk = runtimeEntityType.AddKey(new[] { regionId });
pk.AddAnnotation("Relational:Name", "context_regions_pkey");
runtimeEntityType.SetPrimaryKey(pk);
runtimeEntityType.AddIndex(new[] { sortOrder, regionId },
"ux_platform_context_regions_sort",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
runtimeEntityType.AddAnnotation("Relational:TableName", "context_regions");
}
}
}

View File

@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Platform.Database.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
internal partial class EnvironmentSettingEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Platform.Database.EfCore.Models.EnvironmentSetting",
typeof(EnvironmentSetting),
baseEntityType);
var key = runtimeEntityType.AddProperty(
"Key",
typeof(string),
propertyInfo: typeof(EnvironmentSetting).GetProperty("Key",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
key.AddAnnotation("Relational:ColumnName", "key");
var value = runtimeEntityType.AddProperty(
"Value",
typeof(string),
propertyInfo: typeof(EnvironmentSetting).GetProperty("Value",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
value.AddAnnotation("Relational:ColumnName", "value");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(EnvironmentSetting).GetProperty("UpdatedAt",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(EnvironmentSetting).GetProperty("UpdatedBy",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
updatedBy.AddAnnotation("Relational:DefaultValueSql", "'system'");
var pk = runtimeEntityType.AddKey(new[] { key });
pk.AddAnnotation("Relational:Name", "environment_settings_pkey");
runtimeEntityType.SetPrimaryKey(pk);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
runtimeEntityType.AddAnnotation("Relational:TableName", "environment_settings");
}
}
}

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.Platform.Database.EfCore.CompiledModels;
using StellaOps.Platform.Database.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(PlatformDbContext), typeof(PlatformDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Platform.Database.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
[DbContext(typeof(PlatformDbContext))]
public partial class PlatformDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static PlatformDbContextModel()
{
var model = new PlatformDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (PlatformDbContextModel)model.FinalizeModel();
}
private static PlatformDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,36 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
public partial class PlatformDbContextModel
{
private PlatformDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("b2a4e6c8-1d3f-4a5b-9c7e-0f8a2b4d6e10"), entityTypeCount: 4)
{
}
partial void Initialize()
{
var environmentSetting = EnvironmentSettingEntityType.Create(this);
var contextRegion = ContextRegionEntityType.Create(this);
var contextEnvironment = ContextEnvironmentEntityType.Create(this);
var uiContextPreference = UiContextPreferenceEntityType.Create(this);
EnvironmentSettingEntityType.CreateAnnotations(environmentSetting);
ContextRegionEntityType.CreateAnnotations(contextRegion);
ContextEnvironmentEntityType.CreateAnnotations(contextEnvironment);
UiContextPreferenceEntityType.CreateAnnotations(uiContextPreference);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,99 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Platform.Database.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Platform.Database.EfCore.CompiledModels
{
internal partial class UiContextPreferenceEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Platform.Database.EfCore.Models.UiContextPreference",
typeof(UiContextPreference),
baseEntityType);
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(UiContextPreference).GetProperty("TenantId",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var actorId = runtimeEntityType.AddProperty(
"ActorId",
typeof(string),
propertyInfo: typeof(UiContextPreference).GetProperty("ActorId",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
actorId.AddAnnotation("Relational:ColumnName", "actor_id");
var regions = runtimeEntityType.AddProperty(
"Regions",
typeof(string[]),
propertyInfo: typeof(UiContextPreference).GetProperty("Regions",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
regions.AddAnnotation("Relational:ColumnName", "regions");
regions.AddAnnotation("Relational:DefaultValueSql", "ARRAY[]::text[]");
var environments = runtimeEntityType.AddProperty(
"Environments",
typeof(string[]),
propertyInfo: typeof(UiContextPreference).GetProperty("Environments",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
environments.AddAnnotation("Relational:ColumnName", "environments");
environments.AddAnnotation("Relational:DefaultValueSql", "ARRAY[]::text[]");
var timeWindow = runtimeEntityType.AddProperty(
"TimeWindow",
typeof(string),
propertyInfo: typeof(UiContextPreference).GetProperty("TimeWindow",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
timeWindow.AddAnnotation("Relational:ColumnName", "time_window");
timeWindow.AddAnnotation("Relational:DefaultValueSql", "'24h'");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(UiContextPreference).GetProperty("UpdatedAt",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(UiContextPreference).GetProperty("UpdatedBy",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
updatedBy.AddAnnotation("Relational:DefaultValueSql", "'system'");
var pk = runtimeEntityType.AddKey(new[] { tenantId, actorId });
pk.AddAnnotation("Relational:Name", "ui_context_preferences_pkey");
runtimeEntityType.SetPrimaryKey(pk);
runtimeEntityType.AddIndex(new[] { updatedAt, tenantId, actorId },
"ix_platform_ui_context_preferences_updated");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
runtimeEntityType.AddAnnotation("Relational:TableName", "ui_context_preferences");
}
}
}

View File

@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Platform.Database.EfCore.Models;
namespace StellaOps.Platform.Database.EfCore.Context;
public partial class PlatformDbContext : DbContext
{
private readonly string _schemaName;
public PlatformDbContext(DbContextOptions<PlatformDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "platform"
: schemaName.Trim();
}
public virtual DbSet<EnvironmentSetting> EnvironmentSettings { get; set; }
public virtual DbSet<ContextRegion> ContextRegions { get; set; }
public virtual DbSet<ContextEnvironment> ContextEnvironments { get; set; }
public virtual DbSet<UiContextPreference> UiContextPreferences { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
modelBuilder.Entity<EnvironmentSetting>(entity =>
{
entity.HasKey(e => e.Key).HasName("environment_settings_pkey");
entity.ToTable("environment_settings", schemaName);
entity.Property(e => e.Key).HasColumnName("key");
entity.Property(e => e.Value).HasColumnName("value");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy)
.HasDefaultValueSql("'system'")
.HasColumnName("updated_by");
});
modelBuilder.Entity<ContextRegion>(entity =>
{
entity.HasKey(e => e.RegionId).HasName("context_regions_pkey");
entity.ToTable("context_regions", schemaName);
entity.HasIndex(e => new { e.SortOrder, e.RegionId }, "ux_platform_context_regions_sort")
.IsUnique();
entity.Property(e => e.RegionId).HasColumnName("region_id");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.SortOrder).HasColumnName("sort_order");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
});
modelBuilder.Entity<ContextEnvironment>(entity =>
{
entity.HasKey(e => e.EnvironmentId).HasName("context_environments_pkey");
entity.ToTable("context_environments", schemaName);
entity.HasIndex(e => new { e.RegionId, e.SortOrder, e.EnvironmentId },
"ix_platform_context_environments_region_sort");
entity.HasIndex(e => new { e.SortOrder, e.RegionId, e.EnvironmentId },
"ix_platform_context_environments_sort");
entity.Property(e => e.EnvironmentId).HasColumnName("environment_id");
entity.Property(e => e.RegionId).HasColumnName("region_id");
entity.Property(e => e.EnvironmentType).HasColumnName("environment_type");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.SortOrder).HasColumnName("sort_order");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
});
modelBuilder.Entity<UiContextPreference>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.ActorId }).HasName("ui_context_preferences_pkey");
entity.ToTable("ui_context_preferences", schemaName);
entity.HasIndex(e => new { e.UpdatedAt, e.TenantId, e.ActorId },
"ix_platform_ui_context_preferences_updated")
.IsDescending(true, false, false);
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ActorId).HasColumnName("actor_id");
entity.Property(e => e.Regions)
.HasDefaultValueSql("ARRAY[]::text[]")
.HasColumnName("regions");
entity.Property(e => e.Environments)
.HasDefaultValueSql("ARRAY[]::text[]")
.HasColumnName("environments");
entity.Property(e => e.TimeWindow)
.HasDefaultValueSql("'24h'")
.HasColumnName("time_window");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy)
.HasDefaultValueSql("'system'")
.HasColumnName("updated_by");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Platform.Database.EfCore.Context;
public sealed class PlatformDesignTimeDbContextFactory : IDesignTimeDbContextFactory<PlatformDbContext>
{
private const string DefaultConnectionString = "Host=localhost;Port=55434;Database=postgres;Username=postgres;Password=postgres;Search Path=platform,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_PLATFORM_EF_CONNECTION";
public PlatformDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<PlatformDbContext>()
.UseNpgsql(connectionString)
.Options;
return new PlatformDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Platform.Database.EfCore.Models;
public partial class ContextEnvironment
{
public string EnvironmentId { get; set; } = null!;
public string RegionId { get; set; } = null!;
public string EnvironmentType { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public int SortOrder { get; set; }
public bool Enabled { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Platform.Database.EfCore.Models;
public partial class ContextRegion
{
public string RegionId { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public int SortOrder { get; set; }
public bool Enabled { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Platform.Database.EfCore.Models;
public partial class EnvironmentSetting
{
public string Key { get; set; } = null!;
public string Value { get; set; } = null!;
public DateTime UpdatedAt { get; set; }
public string UpdatedBy { get; set; } = null!;
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Platform.Database.EfCore.Models;
public partial class UiContextPreference
{
public string TenantId { get; set; } = null!;
public string ActorId { get; set; } = null!;
public string[] Regions { get; set; } = [];
public string[] Environments { get; set; } = [];
public string TimeWindow { get; set; } = null!;
public DateTime UpdatedAt { get; set; }
public string UpdatedBy { get; set; } = null!;
}

View File

@@ -0,0 +1,175 @@
using StellaOps.Infrastructure.Postgres.Migrations;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.Database;
/// <summary>
/// Consolidated migration artifact generated from all configured sources of a module.
/// </summary>
public sealed record MigrationModuleConsolidatedArtifact(
string MigrationName,
string Script,
string Checksum,
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> SourceMigrations);
/// <summary>
/// Source migration metadata retained for compatibility backfill.
/// </summary>
public sealed record MigrationModuleConsolidatedSourceMigration(
string Name,
MigrationCategory Category,
string Checksum,
string Content,
string SourceResourceName);
/// <summary>
/// Builds deterministic consolidated migration scripts per service module.
/// </summary>
public static class MigrationModuleConsolidation
{
public static MigrationModuleConsolidatedArtifact Build(MigrationModuleInfo module)
{
ArgumentNullException.ThrowIfNull(module);
var migrationsByName = new Dictionary<string, MigrationModuleConsolidatedSourceMigration>(StringComparer.Ordinal);
foreach (var source in module.Sources)
{
foreach (var migration in LoadMigrationsFromSource(source))
{
if (migrationsByName.TryGetValue(migration.Name, out var existing))
{
if (!string.Equals(existing.Checksum, migration.Checksum, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Duplicate migration name '{migration.Name}' with different content discovered while consolidating module '{module.Name}'.");
}
continue;
}
migrationsByName[migration.Name] = migration;
}
}
if (migrationsByName.Count == 0)
{
throw new InvalidOperationException(
$"Module '{module.Name}' has no migration resources to consolidate.");
}
var sourceMigrations = migrationsByName.Values
.OrderBy(static migration => migration.Name, StringComparer.Ordinal)
.ToArray();
var script = BuildConsolidatedScript(module, sourceMigrations);
var checksum = ComputeChecksum(script);
var migrationName = $"100_consolidated_{NormalizeModuleName(module.Name)}.sql";
return new MigrationModuleConsolidatedArtifact(
migrationName,
script,
checksum,
sourceMigrations);
}
private static IReadOnlyList<MigrationModuleConsolidatedSourceMigration> LoadMigrationsFromSource(
MigrationModuleSourceInfo source)
{
var resources = source.MigrationsAssembly
.GetManifestResourceNames()
.Where(static name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.Where(name =>
string.IsNullOrWhiteSpace(source.ResourcePrefix) ||
name.Contains(source.ResourcePrefix, StringComparison.OrdinalIgnoreCase))
.OrderBy(static name => name, StringComparer.Ordinal);
var migrations = new List<MigrationModuleConsolidatedSourceMigration>();
foreach (var resourceName in resources)
{
using var stream = source.MigrationsAssembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
continue;
}
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var fileName = ExtractFileName(resourceName);
var category = MigrationCategoryExtensions.GetCategory(fileName);
var checksum = ComputeChecksum(content);
migrations.Add(new MigrationModuleConsolidatedSourceMigration(
Name: fileName,
Category: category,
Checksum: checksum,
Content: content,
SourceResourceName: resourceName));
}
return migrations;
}
private static string BuildConsolidatedScript(
MigrationModuleInfo module,
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> sourceMigrations)
{
var builder = new StringBuilder();
builder.Append("-- Consolidated migration for module '");
builder.Append(module.Name);
builder.AppendLine("'.");
builder.Append("-- Generated deterministically from ");
builder.Append(sourceMigrations.Count);
builder.AppendLine(" source migrations.");
builder.AppendLine();
foreach (var migration in sourceMigrations)
{
builder.Append("-- BEGIN ");
builder.AppendLine(migration.SourceResourceName);
builder.AppendLine(migration.Content.TrimEnd());
builder.Append("-- END ");
builder.AppendLine(migration.SourceResourceName);
builder.AppendLine();
}
return builder.ToString();
}
private static string ExtractFileName(string resourceName)
{
var lastSlash = resourceName.LastIndexOf('/');
if (lastSlash >= 0)
{
return resourceName[(lastSlash + 1)..];
}
var parts = resourceName.Split('.');
for (var i = parts.Length - 1; i >= 0; i--)
{
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
{
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
}
}
return resourceName;
}
private static string ComputeChecksum(string content)
{
var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
var bytes = Encoding.UTF8.GetBytes(normalized);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string NormalizeModuleName(string moduleName)
{
var chars = moduleName.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray();
return chars.Length == 0 ? "module" : new string(chars);
}
}

View File

@@ -32,6 +32,23 @@ internal static class MigrationModulePluginDiscovery
$"Invalid migration module plugin '{plugin.GetType().FullName}': schema name is required.");
}
if (module.Sources.Count == 0)
{
throw new InvalidOperationException(
$"Invalid migration module plugin '{plugin.GetType().FullName}': at least one migration source is required.");
}
var sourceSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var source in module.Sources)
{
var sourceIdentity = $"{source.MigrationsAssembly.FullName}|{source.ResourcePrefix}";
if (!sourceSet.Add(sourceIdentity))
{
throw new InvalidOperationException(
$"Invalid migration module plugin '{plugin.GetType().FullName}': duplicate migration source '{sourceIdentity}' for module '{module.Name}'.");
}
}
if (!modulesByName.TryAdd(module.Name, module))
{
throw new InvalidOperationException(
@@ -161,4 +178,3 @@ internal static class MigrationModulePluginDiscovery
return directories.OrderBy(static directory => directory, StringComparer.Ordinal).ToArray();
}
}

View File

@@ -1,100 +1,296 @@
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Attestor.Persistence;
using StellaOps.Eventing.Postgres;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.BinaryIndex.GoldenSet;
using StellaOps.BinaryIndex.Persistence;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using StellaOps.Artifact.Infrastructure;
using StellaOps.Authority.Persistence.Postgres;
using StellaOps.Concelier.Persistence.Postgres;
using StellaOps.Evidence.Persistence.Postgres;
using StellaOps.Excititor.Persistence.Postgres;
using StellaOps.Notify.Persistence.Postgres;
using StellaOps.Plugin.Registry;
using StellaOps.Policy.Persistence.Postgres;
using StellaOps.ReachGraph.Persistence.Postgres;
using StellaOps.Remediation.Persistence.Postgres;
using StellaOps.SbomService.Lineage.Persistence;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Triage;
using StellaOps.Scheduler.Persistence.Postgres;
using StellaOps.Timeline.Core.Postgres;
using StellaOps.TimelineIndexer.Infrastructure;
using StellaOps.Verdict.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Graph.Indexer.Persistence.Postgres;
using StellaOps.Unknowns.Persistence.Postgres;
using StellaOps.VexHub.Persistence.Postgres;
using StellaOps.VexLens.Persistence.Postgres;
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
using StellaOps.Orchestrator.Infrastructure.Postgres;
namespace StellaOps.Platform.Database;
public sealed class AdvisoryAiMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "AdvisoryAI",
schemaName: "advisoryai",
migrationsAssembly: typeof(AdvisoryAiDataSource).Assembly);
}
public sealed class AirGapMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "AirGap",
SchemaName: "airgap",
MigrationsAssembly: typeof(AirGapDataSource).Assembly);
name: "AirGap",
schemaName: "airgap",
migrationsAssembly: typeof(AirGapDataSource).Assembly);
}
public sealed class AttestorMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Attestor",
schemaName: "proofchain",
migrationsAssembly: typeof(ProofChainDbContext).Assembly,
resourcePrefix: "StellaOps.Attestor.Persistence.Migrations");
}
public sealed class BinaryIndexMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "BinaryIndex",
schemaName: "binaries",
sources:
[
new MigrationModuleSourceInfo(
typeof(BinaryIndexMigrationRunner).Assembly,
"StellaOps.BinaryIndex.Persistence.Migrations"),
new MigrationModuleSourceInfo(
typeof(PostgresGoldenSetStore).Assembly,
"StellaOps.BinaryIndex.GoldenSet.Migrations")
]);
}
public sealed class AuthorityMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Authority",
SchemaName: "authority",
MigrationsAssembly: typeof(AuthorityDataSource).Assembly,
ResourcePrefix: "StellaOps.Authority.Persistence.Migrations");
name: "Authority",
schemaName: "authority",
migrationsAssembly: typeof(AuthorityDataSource).Assembly,
resourcePrefix: "StellaOps.Authority.Persistence.Migrations");
}
public sealed class EventingMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Eventing",
schemaName: "timeline",
migrationsAssembly: typeof(EventingDataSource).Assembly);
}
public sealed class GraphMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Graph",
schemaName: "graph",
sources:
[
new MigrationModuleSourceInfo(typeof(GraphIndexerDataSource).Assembly)
]);
}
public sealed class EvidenceMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Evidence",
schemaName: "evidence",
sources:
[
new MigrationModuleSourceInfo(typeof(EvidenceDataSource).Assembly),
new MigrationModuleSourceInfo(typeof(ArtifactDataSource).Assembly)
]);
}
public sealed class EvidenceLockerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "EvidenceLocker",
schemaName: "evidence_locker",
migrationsAssembly: typeof(EvidenceLockerDataSource).Assembly);
}
public sealed class SchedulerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Scheduler",
SchemaName: "scheduler",
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
ResourcePrefix: "StellaOps.Scheduler.Persistence.Migrations");
name: "Scheduler",
schemaName: "scheduler",
migrationsAssembly: typeof(SchedulerDataSource).Assembly,
resourcePrefix: "StellaOps.Scheduler.Persistence.Migrations");
}
public sealed class ConcelierMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Concelier",
SchemaName: "vuln",
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
ResourcePrefix: "StellaOps.Concelier.Persistence.Migrations");
name: "Concelier",
schemaName: "vuln",
migrationsAssembly: typeof(ConcelierDataSource).Assembly,
resourcePrefix: "StellaOps.Concelier.Persistence.Migrations");
}
public sealed class PolicyMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Policy",
SchemaName: "policy",
MigrationsAssembly: typeof(PolicyDataSource).Assembly,
ResourcePrefix: "StellaOps.Policy.Persistence.Migrations");
name: "Policy",
schemaName: "policy",
migrationsAssembly: typeof(PolicyDataSource).Assembly,
resourcePrefix: "StellaOps.Policy.Persistence.Migrations");
}
public sealed class NotifyMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Notify",
SchemaName: "notify",
MigrationsAssembly: typeof(NotifyDataSource).Assembly,
ResourcePrefix: "StellaOps.Notify.Persistence.Migrations");
name: "Notify",
schemaName: "notify",
migrationsAssembly: typeof(NotifyDataSource).Assembly,
resourcePrefix: "StellaOps.Notify.Persistence.Migrations");
}
public sealed class ExcititorMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Excititor",
SchemaName: "vex",
MigrationsAssembly: typeof(ExcititorDataSource).Assembly,
ResourcePrefix: "StellaOps.Excititor.Persistence.Migrations");
name: "Excititor",
schemaName: "vex",
migrationsAssembly: typeof(ExcititorDataSource).Assembly,
resourcePrefix: "StellaOps.Excititor.Persistence.Migrations");
}
public sealed class PluginRegistryMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "PluginRegistry",
schemaName: "platform",
migrationsAssembly: typeof(PluginRegistryMigrationRunner).Assembly,
resourcePrefix: "StellaOps.Plugin.Registry.Migrations");
}
public sealed class PlatformMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Platform",
SchemaName: "release",
MigrationsAssembly: typeof(ReleaseMigrationRunner).Assembly,
ResourcePrefix: "StellaOps.Platform.Database.Migrations.Release");
name: "Platform",
schemaName: "release",
migrationsAssembly: typeof(ReleaseMigrationRunner).Assembly,
resourcePrefix: "StellaOps.Platform.Database.Migrations.Release");
}
public sealed class ScannerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Scanner",
SchemaName: "scanner",
MigrationsAssembly: typeof(ScannerDataSource).Assembly);
name: "Scanner",
schemaName: "scanner",
sources:
[
new MigrationModuleSourceInfo(typeof(ScannerDataSource).Assembly),
new MigrationModuleSourceInfo(
typeof(TriageDbContext).Assembly,
"StellaOps.Scanner.Triage.Migrations")
]);
}
public sealed class SignalsMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Signals",
schemaName: "signals",
migrationsAssembly: typeof(SignalsDataSource).Assembly);
}
public sealed class TimelineIndexerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "TimelineIndexer",
SchemaName: "timeline",
MigrationsAssembly: typeof(TimelineIndexerDataSource).Assembly,
ResourcePrefix: "StellaOps.TimelineIndexer.Infrastructure.Db.Migrations");
name: "TimelineIndexer",
schemaName: "timeline",
sources:
[
new MigrationModuleSourceInfo(
typeof(TimelineIndexerDataSource).Assembly,
"StellaOps.TimelineIndexer.Infrastructure.Db.Migrations"),
new MigrationModuleSourceInfo(
typeof(TimelineCoreDataSource).Assembly)
]);
}
public sealed class VexHubMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "VexHub",
schemaName: "vexhub",
migrationsAssembly: typeof(VexHubDataSource).Assembly);
}
public sealed class RemediationMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Remediation",
schemaName: "remediation",
migrationsAssembly: typeof(RemediationDataSource).Assembly);
}
public sealed class VexLensMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "VexLens",
schemaName: "vexlens",
migrationsAssembly: typeof(VexLensDataSource).Assembly);
}
public sealed class SbomLineageMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "SbomLineage",
schemaName: "sbom",
migrationsAssembly: typeof(LineageDataSource).Assembly);
}
public sealed class ReachGraphMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "ReachGraph",
schemaName: "reachgraph",
migrationsAssembly: typeof(ReachGraphDataSource).Assembly);
}
public sealed class UnknownsMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Unknowns",
schemaName: "unknowns",
migrationsAssembly: typeof(UnknownsDataSource).Assembly);
}
public sealed class VerdictMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Verdict",
schemaName: "stellaops",
migrationsAssembly: typeof(VerdictDataSource).Assembly,
resourcePrefix: "StellaOps.Verdict.Persistence.Migrations");
}
public sealed class OrchestratorMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "Orchestrator",
schemaName: "orchestrator",
migrationsAssembly: typeof(OrchestratorDataSource).Assembly);
}
public sealed class FindingsLedgerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "FindingsLedger",
schemaName: "public",
migrationsAssembly: typeof(LedgerDataSource).Assembly,
resourcePrefix: "StellaOps.Findings.Ledger.migrations");
}

View File

@@ -4,14 +4,62 @@ using System.Threading;
namespace StellaOps.Platform.Database;
/// <summary>
/// Defines a PostgreSQL module with migration metadata.
/// Defines one migration source (assembly + optional resource prefix) for a service module.
/// </summary>
public sealed record MigrationModuleInfo(
string Name,
string SchemaName,
public sealed record MigrationModuleSourceInfo(
Assembly MigrationsAssembly,
string? ResourcePrefix = null);
/// <summary>
/// Defines a PostgreSQL module with migration metadata.
/// </summary>
public sealed record MigrationModuleInfo
{
public MigrationModuleInfo(
string name,
string schemaName,
Assembly migrationsAssembly,
string? resourcePrefix = null)
: this(
name,
schemaName,
[new MigrationModuleSourceInfo(migrationsAssembly, resourcePrefix)])
{
}
public MigrationModuleInfo(
string name,
string schemaName,
IReadOnlyList<MigrationModuleSourceInfo> sources)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
ArgumentNullException.ThrowIfNull(sources);
if (sources.Count == 0)
{
throw new ArgumentException("At least one migration source is required.", nameof(sources));
}
if (sources.Any(static source => source.MigrationsAssembly is null))
{
throw new ArgumentException("Migration source assembly cannot be null.", nameof(sources));
}
Name = name;
SchemaName = schemaName;
Sources = sources.ToArray();
MigrationsAssembly = Sources[0].MigrationsAssembly;
ResourcePrefix = Sources[0].ResourcePrefix;
}
public string Name { get; }
public string SchemaName { get; }
public Assembly MigrationsAssembly { get; }
public string? ResourcePrefix { get; }
public IReadOnlyList<MigrationModuleSourceInfo> Sources { get; }
}
/// <summary>
/// Canonical PostgreSQL migration module registry owned by Platform.
/// </summary>

View File

@@ -0,0 +1,14 @@
-- Release schema prerequisite for tenant fallback lookups.
-- Keeps clean-install migration execution independent from optional shared-schema owners.
CREATE SCHEMA IF NOT EXISTS shared;
CREATE TABLE IF NOT EXISTS shared.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_shared_tenants_single_default
ON shared.tenants (is_default)
WHERE is_default;

View File

@@ -151,10 +151,11 @@ CREATE TABLE IF NOT EXISTS release.release_tags (
release_id UUID NOT NULL REFERENCES release.releases(id) ON DELETE CASCADE,
environment_id UUID REFERENCES release.environments(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
PRIMARY KEY (tenant_id, tag, COALESCE(environment_id, '00000000-0000-0000-0000-000000000000'::UUID))
created_by UUID NOT NULL
);
CREATE UNIQUE INDEX idx_release_tags_tenant_tag_environment
ON release.release_tags (tenant_id, tag, COALESCE(environment_id, '00000000-0000-0000-0000-000000000000'::UUID));
CREATE INDEX idx_release_tags_release ON release.release_tags(release_id);
CREATE INDEX idx_release_tags_environment ON release.release_tags(environment_id)
WHERE environment_id IS NOT NULL;

View File

@@ -85,13 +85,14 @@ COMMENT ON COLUMN release.agent_capabilities.version IS 'Version of the capabili
-- ============================================================================
CREATE TABLE IF NOT EXISTS release.agent_heartbeats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
agent_id UUID NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status JSONB NOT NULL,
latency_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
-- No FK to agents for partition performance
) PARTITION BY RANGE (created_at);

View File

@@ -87,10 +87,11 @@ CREATE TABLE IF NOT EXISTS release.plugins (
entry_point TEXT,
config_defaults JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_plugins_scope_name
ON release.plugins (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name);
CREATE INDEX idx_plugins_tenant ON release.plugins(tenant_id);
CREATE INDEX idx_plugins_type ON release.plugins(plugin_type_id);
CREATE INDEX idx_plugins_enabled ON release.plugins(tenant_id, is_enabled)
@@ -154,10 +155,11 @@ CREATE TABLE IF NOT EXISTS release.plugin_instances (
invocation_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, plugin_id, COALESCE(instance_name, ''))
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_plugin_instances_scope_name
ON release.plugin_instances (tenant_id, plugin_id, COALESCE(instance_name, ''));
CREATE INDEX idx_plugin_instances_tenant ON release.plugin_instances(tenant_id);
CREATE INDEX idx_plugin_instances_plugin ON release.plugin_instances(plugin_id);
CREATE INDEX idx_plugin_instances_enabled ON release.plugin_instances(tenant_id, is_enabled)

View File

@@ -28,11 +28,11 @@ CREATE TABLE IF NOT EXISTS release.policy_profiles (
on_fail_hard TEXT[] NOT NULL DEFAULT '{}',
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure unique names within tenant scope (NULL tenant = instance level)
UNIQUE (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_policy_profiles_scope_name
ON release.policy_profiles (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name);
CREATE INDEX idx_policy_profiles_tenant ON release.policy_profiles(tenant_id);
CREATE INDEX idx_policy_profiles_type ON release.policy_profiles(profile_type);
CREATE INDEX idx_policy_profiles_default ON release.policy_profiles(tenant_id)

View File

@@ -0,0 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Platform.Database.EfCore.CompiledModels;
using StellaOps.Platform.Database.EfCore.Context;
namespace StellaOps.Platform.Database.Postgres;
public static class PlatformDbContextFactory
{
public const string DefaultSchemaName = "platform";
public static PlatformDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<PlatformDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(PlatformDbContextModel.Instance);
}
return new PlatformDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -11,16 +11,38 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\AdvisoryAI\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\..\..\AirGap\__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj" />
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj" />
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj" />
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.GoldenSet\StellaOps.BinaryIndex.GoldenSet.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Artifact.Infrastructure\StellaOps.Artifact.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj" />
<ProjectReference Include="..\..\..\Graph\__Libraries\StellaOps.Graph.Indexer.Persistence\StellaOps.Graph.Indexer.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Persistence\StellaOps.Evidence.Persistence.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Registry\StellaOps.Plugin.Registry.csproj" />
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Persistence\StellaOps.Policy.Persistence.csproj" />
<ProjectReference Include="..\..\..\SbomService\__Libraries\StellaOps.SbomService.Lineage\StellaOps.SbomService.Lineage.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Persistence\StellaOps.Signals.Persistence.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Triage\StellaOps.Scanner.Triage.csproj" />
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Persistence\StellaOps.Scheduler.Persistence.csproj" />
<ProjectReference Include="..\..\..\Timeline\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Unknowns\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
<ProjectReference Include="..\..\..\VexHub\__Libraries\StellaOps.VexHub.Persistence\StellaOps.VexHub.Persistence.csproj" />
<ProjectReference Include="..\..\..\VexLens\StellaOps.VexLens.Persistence\StellaOps.VexLens.Persistence.csproj" />
<ProjectReference Include="..\..\..\Remediation\StellaOps.Remediation.Persistence\StellaOps.Remediation.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.ReachGraph.Persistence\StellaOps.ReachGraph.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Verdict\StellaOps.Verdict.csproj" />
<ProjectReference Include="..\..\..\EvidenceLocker\StellaOps.EvidenceLocker\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
<ProjectReference Include="..\..\..\Orchestrator\StellaOps.Orchestrator\StellaOps.Orchestrator.Infrastructure\StellaOps.Orchestrator.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
@@ -28,6 +50,17 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\PlatformDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>

View File

@@ -6,6 +6,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| SPRINT_20260222_051-MGC-04-W1 | DONE | Added platform-owned `MigrationModuleRegistry` canonical module catalog for migration runner entrypoint consolidation; CLI now consumes this registry instead of owning module metadata. |
| SPRINT_20260222_051-MGC-04-W1-PLUGINS | DONE | Replaced hardcoded module catalog with auto-discovered migration plugins (`IMigrationModulePlugin`) so one consolidated plugin descriptor per web service feeds both CLI and Platform API migration execution paths. |
| SPRINT_20260222_051-MGC-04-W1-SOURCES | DONE | Extended service plugin model to support source-set flattening (multiple migration sources per web service), including Scanner storage+triage source registration under one `ScannerMigrationModulePlugin`, plus synthesized per-plugin consolidated migration artifact generation for empty-history execution and partial-history backfill self-healing. |
| B22-01-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `047_GlobalContextAndFilters.sql` with `platform.context_regions`, `platform.context_environments`, and `platform.ui_context_preferences`. |
| B22-02-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `048_ReleaseReadModels.sql` with release list/activity/approvals projection tables, correlation keys, and deterministic ordering indexes. |
| B22-03-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `049_TopologyInventory.sql` with normalized topology inventory projection tables and sync-watermark indexes. |
@@ -13,3 +14,8 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| B22-05-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `051_IntegrationSourceHealth.sql` for integrations feed and VEX source health/freshness read-model projection objects. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| PLATFORM-EF-01 | DONE | Sprint `docs/implplan/SPRINT_20260222_096_Platform_dal_to_efcore.md`: verified AGENTS.md alignment and `PlatformMigrationModulePlugin` registration in migration registry. |
| PLATFORM-EF-02 | DONE | Sprint 096: scaffolded EF Core model baseline under `EfCore/Context/`, `EfCore/Models/`, `EfCore/CompiledModels/` for platform schema tables (`environment_settings`, `context_regions`, `context_environments`, `ui_context_preferences`). |
| PLATFORM-EF-03 | DONE | Sprint 096: converted `PostgresEnvironmentSettingsStore` and `PostgresPlatformContextStore` reads to EF Core LINQ with `AsNoTracking()`; PostgreSQL-specific upserts retained as raw SQL. `PostgresScoreHistoryStore` retained as raw Npgsql (cross-module signals schema). |
| PLATFORM-EF-04 | DONE | Sprint 096: added design-time factory (`PlatformDesignTimeDbContextFactory`), runtime factory (`PlatformDbContextFactory`) with `UseModel(PlatformDbContextModel.Instance)` for default schema, compiled model stubs with `// <auto-generated />` header, assembly attribute exclusion in csproj. |
| PLATFORM-EF-05 | DONE | Sprint 096: sequential builds pass for Platform.Database (0W/0E), Platform.WebService (0W/0E), Platform.WebService.Tests (0W/0E). TASKS.md and sprint tracker updated. |

View File

@@ -93,6 +93,64 @@ public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplication
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Preferences_AreIsolatedPerTenantForSameActor()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "shared-actor");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
var tenantARequest = new PlatformContextPreferencesRequest(
Regions: new[] { "us-east" },
Environments: new[] { "us-prod" },
TimeWindow: "7d");
var tenantAResponse = await client.PutAsJsonAsync(
"/api/v2/context/preferences",
tenantARequest,
TestContext.Current.CancellationToken);
tenantAResponse.EnsureSuccessStatusCode();
var tenantAPreferences = await tenantAResponse.Content.ReadFromJsonAsync<PlatformContextPreferences>(
TestContext.Current.CancellationToken);
Assert.NotNull(tenantAPreferences);
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-b");
var tenantBDefaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantBDefaults);
Assert.Equal("tenant-context-b", tenantBDefaults!.TenantId);
Assert.NotEqual("7d", tenantBDefaults.TimeWindow);
Assert.NotEqual(tenantAPreferences!.Regions.ToArray(), tenantBDefaults.Regions.ToArray());
var tenantBRequest = new PlatformContextPreferencesRequest(
Regions: new[] { "eu-west" },
Environments: new[] { "eu-prod" },
TimeWindow: "30d");
var tenantBUpdate = await client.PutAsJsonAsync(
"/api/v2/context/preferences",
tenantBRequest,
TestContext.Current.CancellationToken);
tenantBUpdate.EnsureSuccessStatusCode();
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
var tenantAReloaded = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantAReloaded);
Assert.Equal("tenant-context-a", tenantAReloaded!.TenantId);
Assert.Equal(new[] { "us-east" }, tenantAReloaded.Regions.ToArray());
Assert.Equal(new[] { "us-prod" }, tenantAReloaded.Environments.ToArray());
Assert.Equal("7d", tenantAReloaded.TimeWindow);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()

View File

@@ -13,16 +13,34 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
{
private static readonly string[] ExpectedModules =
[
"AdvisoryAI",
"AirGap",
"Attestor",
"Authority",
"BinaryIndex",
"Concelier",
"Eventing",
"Evidence",
"EvidenceLocker",
"Excititor",
"FindingsLedger",
"Graph",
"Notify",
"Orchestrator",
"Platform",
"PluginRegistry",
"Policy",
"ReachGraph",
"Remediation",
"SbomLineage",
"Scanner",
"Scheduler",
"TimelineIndexer"
"Signals",
"TimelineIndexer",
"Unknowns",
"Verdict",
"VexHub",
"VexLens"
];
private readonly PlatformWebApplicationFactory _factory;
@@ -139,4 +157,18 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
Assert.NotNull(problem);
Assert.Equal("Release migration approval required", problem!.Title);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Modules_WithoutTenantHeader_RemainsAccessibleAsSystemEndpoint()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-system-test");
var response = await client.GetAsync(
"/api/v1/admin/migrations/modules",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -1,4 +1,5 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
@@ -37,4 +38,19 @@ public sealed class OnboardingEndpointsTests : IClassFixture<PlatformWebApplicat
state.Steps.OrderBy(item => item.Step, System.StringComparer.Ordinal).Select(item => item.Step),
state.Steps.Select(item => item.Step));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Onboarding_SetupStatusRoute_RejectsCrossTenantAccess()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-onboarding-a");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-onboarding");
var response = await client.GetAsync(
"/api/v1/platform/tenants/tenant-onboarding-b/setup-status",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,135 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PlatformRequestContextResolverTests
{
private readonly PlatformRequestContextResolver _resolver = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithCanonicalClaim_UsesClaimTenant()
{
var context = CreateContext(
claims:
[
new Claim(StellaOpsClaimTypes.Tenant, "Tenant-A"),
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
],
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.True(success);
Assert.NotNull(requestContext);
Assert.Equal("tenant-a", requestContext!.TenantId);
Assert.Equal("subject-a", requestContext.ActorId);
Assert.Null(error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithConflictingClaimAndHeader_ReturnsTenantConflict()
{
var context = CreateContext(
claims:
[
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
],
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-b",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_conflict", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithLegacyTidClaim_UsesLegacyClaimFallback()
{
var context = CreateContext(
claims:
[
new Claim("tid", "legacy-tenant"),
new Claim(StellaOpsClaimTypes.ClientId, "client-a"),
]);
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.True(success);
Assert.NotNull(requestContext);
Assert.Equal("legacy-tenant", requestContext!.TenantId);
Assert.Equal("client-a", requestContext.ActorId);
Assert.Null(error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithConflictingCanonicalAndLegacyHeaders_ReturnsTenantConflict()
{
var context = CreateContext(
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
["X-Stella-Tenant"] = "tenant-b",
["X-StellaOps-Actor"] = "actor-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_conflict", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithoutTenant_ReturnsTenantMissing()
{
var context = CreateContext(
headers: new Dictionary<string, string>
{
["X-StellaOps-Actor"] = "actor-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_missing", error);
}
private static DefaultHttpContext CreateContext(
IReadOnlyList<Claim>? claims = null,
IReadOnlyDictionary<string, string>? headers = null)
{
var context = new DefaultHttpContext();
if (claims is not null)
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
if (headers is not null)
{
foreach (var pair in headers)
{
context.Request.Headers[pair.Key] = pair.Value;
}
}
return context;
}
}

View File

@@ -105,4 +105,23 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
Assert.NotNull(body);
Assert.Equal("quotaId is required.", body!["error"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Quotas_TenantRoute_RejectsCrossTenantAccess()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas-a");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-quotas");
var response = await client.GetAsync(
"/api/v1/platform/quotas/tenants/tenant-quotas-b",
TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
}
}

View File

@@ -148,6 +148,38 @@ public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebA
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task TopologyEndpoints_IsolateDataAcrossTenantsWithOverlappingReleaseSlugs()
{
using var tenantAClient = CreateTenantClient("tenant-topology-a");
using var tenantBClient = CreateTenantClient("tenant-topology-b");
await SeedReleaseAsync(tenantAClient, "shared-release", "Shared Release", "us-prod", "promotion");
await SeedReleaseAsync(tenantBClient, "shared-release", "Shared Release", "us-prod", "promotion");
var tenantATargets = await tenantAClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=200&offset=0",
TestContext.Current.CancellationToken);
var tenantBTargets = await tenantBClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantATargets);
Assert.NotNull(tenantBTargets);
Assert.Equal("tenant-topology-a", tenantATargets!.TenantId);
Assert.Equal("tenant-topology-b", tenantBTargets!.TenantId);
Assert.Equal(2, tenantATargets.Items.Count);
Assert.Equal(2, tenantBTargets.Items.Count);
var tenantBTargetIds = tenantBTargets.Items
.Select(item => item.TargetId)
.ToHashSet(StringComparer.Ordinal);
Assert.DoesNotContain(
tenantATargets.Items.Select(item => item.TargetId),
targetId => tenantBTargetIds.Contains(targetId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TopologyEndpoints_RequireExpectedPolicy()