Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

36
src/Platform/AGENTS.md Normal file
View File

@@ -0,0 +1,36 @@
# Platform Service (StellaOps.Platform)
## Mission
Define and deliver the Platform Service that aggregates cross-service views for the Console UI and CLI (health, quotas, onboarding, preferences, global search).
## Roles
- Backend engineer: service APIs, aggregation, caching, and contracts.
- QA automation engineer: deterministic tests, offline cache coverage, integration harness.
- Docs maintainer: platform service architecture, API contracts, and runbooks.
## Operating principles
- Aggregation-only: never mutate raw evidence or policy results.
- Deterministic outputs: stable ordering, UTC timestamps, content-addressed cache keys.
- Offline-first: cache last-known snapshots and surface "data as of" metadata.
- Tenancy-aware: enforce Authority claims and scope filters on every request.
## Required reading
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/platform/architecture.md`
- `docs/modules/platform/platform-service.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/gateway/architecture.md`
- `docs/modules/authority/architecture.md`
## Working directory
- `src/Platform/StellaOps.Platform.WebService` (to be created)
## Testing expectations
- Unit tests for aggregation ordering and error handling.
- Integration tests for fan-out to downstream services with deterministic fixtures.
- Offline cache tests that validate "data as of" metadata and read-only behavior.
## Working agreement
- Update sprint status in `docs/implplan/SPRINT_*.md` when starting/stopping work.
- Document cross-module contract changes in sprint Decisions & Risks.
- Avoid non-deterministic data ordering or timestamps in responses.

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Platform.WebService.Constants;
public static class PlatformPolicies
{
public const string HealthRead = "platform.health.read";
public const string HealthAdmin = "platform.health.admin";
public const string QuotaRead = "platform.quota.read";
public const string QuotaAdmin = "platform.quota.admin";
public const string OnboardingRead = "platform.onboarding.read";
public const string OnboardingWrite = "platform.onboarding.write";
public const string PreferencesRead = "platform.preferences.read";
public const string PreferencesWrite = "platform.preferences.write";
public const string SearchRead = "platform.search.read";
public const string MetadataRead = "platform.metadata.read";
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Platform.WebService.Constants;
public static class PlatformScopes
{
public const string OpsHealth = "ops.health";
public const string OpsAdmin = "ops.admin";
public const string QuotaRead = "quota.read";
public const string QuotaAdmin = "quota.admin";
public const string OnboardingRead = "onboarding.read";
public const string OnboardingWrite = "onboarding.write";
public const string PreferencesRead = "ui.preferences.read";
public const string PreferencesWrite = "ui.preferences.write";
public const string SearchRead = "search.read";
public const string MetadataRead = "platform.metadata.read";
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformHealthSummary(
string Status,
int IncidentCount,
IReadOnlyList<PlatformHealthServiceStatus> Services);
public sealed record PlatformHealthServiceStatus(
string Service,
string Status,
string? Detail,
DateTimeOffset CheckedAt,
double? LatencyMs);
public sealed record PlatformDependencyStatus(
string Service,
string Status,
string Version,
DateTimeOffset CheckedAt,
string? Message);
public sealed record PlatformIncident(
string IncidentId,
string Severity,
string Status,
string Summary,
DateTimeOffset OpenedAt,
DateTimeOffset? UpdatedAt);
public sealed record PlatformHealthMetric(
string Metric,
double Value,
string Unit,
string Status,
double? Threshold,
DateTimeOffset SampledAt);

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformMetadata(
string Service,
string Version,
string? BuildVersion,
string? Environment,
string? Region,
bool OfflineMode,
IReadOnlyList<PlatformCapability> Capabilities);
public sealed record PlatformCapability(
string Id,
string Description,
bool Enabled);

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformOnboardingStepStatus(
string Step,
string Status,
DateTimeOffset? UpdatedAt,
string? UpdatedBy,
string? Notes);
public sealed record PlatformOnboardingState(
string TenantId,
string ActorId,
string Status,
IReadOnlyList<PlatformOnboardingStepStatus> Steps,
DateTimeOffset UpdatedAt,
string? UpdatedBy,
string? SkippedReason);
public sealed record PlatformOnboardingSkipRequest(
string? Reason);
public sealed record PlatformTenantSetupStatus(
string TenantId,
int TotalUsers,
int CompletedUsers,
int SkippedUsers,
int PendingUsers,
DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformItemResponse<T>(
string TenantId,
string ActorId,
DateTimeOffset DataAsOf,
bool Cached,
int CacheTtlSeconds,
T Item);
public sealed record PlatformListResponse<T>(
string TenantId,
string ActorId,
DateTimeOffset DataAsOf,
bool Cached,
int CacheTtlSeconds,
IReadOnlyList<T> Items,
int Count,
int? Limit = null,
int? Offset = null,
string? Query = null);

View File

@@ -0,0 +1,28 @@
using System;
using System.Text.Json.Nodes;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformDashboardPreferences(
string TenantId,
string ActorId,
JsonObject Preferences,
DateTimeOffset UpdatedAt,
string? UpdatedBy);
public sealed record PlatformDashboardPreferencesRequest(
JsonObject Preferences);
public sealed record PlatformDashboardProfile(
string ProfileId,
string Name,
string? Description,
JsonObject Preferences,
DateTimeOffset UpdatedAt,
string? UpdatedBy);
public sealed record PlatformDashboardProfileRequest(
string ProfileId,
string Name,
string? Description,
JsonObject Preferences);

View File

@@ -0,0 +1,29 @@
using System;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformQuotaUsage(
string QuotaId,
string Scope,
string Unit,
decimal Limit,
decimal Used,
decimal Remaining,
string Period,
string Source,
DateTimeOffset MeasuredAt);
public sealed record PlatformQuotaAlert(
string AlertId,
string QuotaId,
string Severity,
decimal Threshold,
string Condition,
DateTimeOffset CreatedAt,
string CreatedBy);
public sealed record PlatformQuotaAlertRequest(
string QuotaId,
decimal Threshold,
string Condition,
string Severity);

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformSearchItem(
string EntityId,
string EntityType,
string Title,
string Summary,
string Source,
string? Url,
double Score,
DateTimeOffset UpdatedAt);
public sealed record PlatformSearchResult(
IReadOnlyList<PlatformSearchItem> Items,
int Total,
int Limit,
int Offset,
string? Query);

View File

@@ -0,0 +1,495 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
namespace StellaOps.Platform.WebService.Endpoints;
public static class PlatformEndpoints
{
public static IEndpointRouteBuilder MapPlatformEndpoints(this IEndpointRouteBuilder app)
{
var platform = app.MapGroup("/api/v1/platform")
.WithTags("Platform");
MapHealthEndpoints(platform);
MapQuotaEndpoints(platform);
MapOnboardingEndpoints(platform);
MapPreferencesEndpoints(platform);
MapSearchEndpoints(app, platform);
MapMetadataEndpoints(platform);
return app;
}
private static void MapHealthEndpoints(IEndpointRouteBuilder platform)
{
var health = platform.MapGroup("/health").WithTags("Platform Health");
health.MapGet("/summary", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformHealthService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformItemResponse<PlatformHealthSummary>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value));
}).RequireAuthorization(PlatformPolicies.HealthRead);
health.MapGet("/dependencies", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformHealthService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetDependenciesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformDependencyStatus>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.HealthRead);
health.MapGet("/incidents", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformHealthService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetIncidentsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformIncident>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.HealthRead);
health.MapGet("/metrics", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformHealthService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetMetricsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformHealthMetric>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.HealthAdmin);
}
private static void MapQuotaEndpoints(IEndpointRouteBuilder platform)
{
var quotas = platform.MapGroup("/quotas").WithTags("Platform Quotas");
quotas.MapGet("/summary", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformQuotaService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformQuotaService service,
string tenantId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "tenant_missing" });
}
var result = await service.GetTenantAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapGet("/alerts", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformQuotaService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetAlertsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformQuotaAlert>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value,
result.Value.Count));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
quotas.MapPost("/alerts", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformQuotaService service,
PlatformQuotaAlertRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var alert = await service.CreateAlertAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/platform/quotas/alerts/{alert.AlertId}", alert);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
}
private static void MapOnboardingEndpoints(IEndpointRouteBuilder platform)
{
var onboarding = platform.MapGroup("/onboarding").WithTags("Platform Onboarding");
onboarding.MapGet("/status", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformOnboardingService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var state = await service.GetStatusAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(state);
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
onboarding.MapPost("/complete/{step}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformOnboardingService service,
string step,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var state = await service.CompleteStepAsync(requestContext!, step, cancellationToken).ConfigureAwait(false);
return Results.Ok(state);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.OnboardingWrite);
onboarding.MapPost("/skip", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformOnboardingService service,
PlatformOnboardingSkipRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var state = await service.SkipAsync(requestContext!, request?.Reason, cancellationToken).ConfigureAwait(false);
return Results.Ok(state);
}).RequireAuthorization(PlatformPolicies.OnboardingWrite);
platform.MapGet("/tenants/{tenantId}/setup-status", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformOnboardingService service,
string tenantId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "tenant_missing" });
}
var status = await service.GetTenantSetupStatusAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
return Results.Ok(status);
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
}
private static void MapPreferencesEndpoints(IEndpointRouteBuilder platform)
{
var preferences = platform.MapGroup("/preferences").WithTags("Platform Preferences");
preferences.MapGet("/dashboard", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var prefs = await service.GetPreferencesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(prefs);
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
preferences.MapPut("/dashboard", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
PlatformDashboardPreferencesRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var prefs = await service.UpsertPreferencesAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(prefs);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
var profiles = platform.MapGroup("/dashboard/profiles").WithTags("Platform Preferences");
profiles.MapGet("/", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var items = await service.GetProfilesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(items);
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
profiles.MapGet("/{profileId}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
string profileId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var profile = await service.GetProfileAsync(requestContext!, profileId, cancellationToken).ConfigureAwait(false);
return profile is null ? Results.NotFound() : Results.Ok(profile);
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
profiles.MapPost("/", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
PlatformDashboardProfileRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var profile = await service.CreateProfileAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/platform/dashboard/profiles/{profile.ProfileId}", profile);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
}
private static void MapSearchEndpoints(IEndpointRouteBuilder app, IEndpointRouteBuilder platform)
{
var searchGroup = platform.MapGroup("/search").WithTags("Platform Search");
async Task<IResult> HandleSearch(
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSearchService service,
[AsParameters] SearchQuery query,
CancellationToken cancellationToken)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var sources = query.Sources
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
var result = await service.SearchAsync(
requestContext!,
query.Query,
sources,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<PlatformSearchItem>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value.Items,
result.Value.Total,
result.Value.Limit,
result.Value.Offset,
result.Value.Query));
}
searchGroup.MapGet("/", HandleSearch).RequireAuthorization(PlatformPolicies.SearchRead);
app.MapGet("/api/v1/search", HandleSearch)
.WithTags("Platform Search")
.RequireAuthorization(PlatformPolicies.SearchRead);
}
private static void MapMetadataEndpoints(IEndpointRouteBuilder platform)
{
platform.MapGet("/metadata", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformMetadataService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetMetadataAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformItemResponse<PlatformMetadata>(
requestContext!.TenantId,
requestContext.ActorId,
result.DataAsOf,
result.Cached,
result.CacheTtlSeconds,
result.Value));
}).RequireAuthorization(PlatformPolicies.MetadataRead);
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
private sealed record SearchQuery(
[FromQuery(Name = "q")] string? Query,
string? Sources,
int? Limit,
int? Offset);
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Options;
public sealed class PlatformServiceOptions
{
public const string SectionName = "Platform";
public PlatformAuthorityOptions Authority { get; set; } = new();
public PlatformCacheOptions Cache { get; set; } = new();
public PlatformSearchOptions Search { get; set; } = new();
public PlatformMetadataOptions Metadata { get; set; } = new();
public PlatformStorageOptions Storage { get; set; } = new();
public void Validate()
{
Authority.Validate();
Cache.Validate();
Search.Validate();
Metadata.Validate();
Storage.Validate();
}
}
public sealed class PlatformAuthorityOptions
{
public string Issuer { get; set; } = "https://auth.stellaops.local";
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public List<string> Audiences { get; set; } = new() { "stellaops-api" };
public List<string> RequiredScopes { get; set; } = new();
public List<string> RequiredTenants { get; set; } = new();
public List<string> BypassNetworks { get; set; } = new();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Platform authority issuer is required.");
}
}
}
public sealed class PlatformCacheOptions
{
public int HealthSummarySeconds { get; set; } = 15;
public int HealthDependenciesSeconds { get; set; } = 60;
public int HealthIncidentsSeconds { get; set; } = 30;
public int HealthMetricsSeconds { get; set; } = 15;
public int QuotaSummarySeconds { get; set; } = 30;
public int QuotaTenantSeconds { get; set; } = 30;
public int QuotaAlertsSeconds { get; set; } = 15;
public int SearchSeconds { get; set; } = 20;
public int MetadataSeconds { get; set; } = 60;
public void Validate()
{
RequireNonNegative(HealthSummarySeconds, nameof(HealthSummarySeconds));
RequireNonNegative(HealthDependenciesSeconds, nameof(HealthDependenciesSeconds));
RequireNonNegative(HealthIncidentsSeconds, nameof(HealthIncidentsSeconds));
RequireNonNegative(HealthMetricsSeconds, nameof(HealthMetricsSeconds));
RequireNonNegative(QuotaSummarySeconds, nameof(QuotaSummarySeconds));
RequireNonNegative(QuotaTenantSeconds, nameof(QuotaTenantSeconds));
RequireNonNegative(QuotaAlertsSeconds, nameof(QuotaAlertsSeconds));
RequireNonNegative(SearchSeconds, nameof(SearchSeconds));
RequireNonNegative(MetadataSeconds, nameof(MetadataSeconds));
}
private static void RequireNonNegative(int value, string name)
{
if (value < 0)
{
throw new InvalidOperationException($"{name} must be zero or greater.");
}
}
}
public sealed class PlatformSearchOptions
{
public int DefaultLimit { get; set; } = 25;
public int MaxLimit { get; set; } = 200;
public void Validate()
{
if (DefaultLimit <= 0)
{
throw new InvalidOperationException("Search default limit must be greater than zero.");
}
if (MaxLimit < DefaultLimit)
{
throw new InvalidOperationException("Search max limit must be greater than or equal to default limit.");
}
}
}
public sealed class PlatformMetadataOptions
{
public string? BuildVersion { get; set; }
public string? Environment { get; set; }
public string? Region { get; set; }
public bool OfflineMode { get; set; }
public void Validate()
{
if (Environment is not null && string.IsNullOrWhiteSpace(Environment))
{
Environment = null;
}
if (Region is not null && string.IsNullOrWhiteSpace(Region))
{
Region = null;
}
}
}
public sealed class PlatformStorageOptions
{
public string Driver { get; set; } = "memory";
public string? PostgresConnectionString { get; set; }
public string Schema { get; set; } = "platform";
public void Validate()
{
if (string.IsNullOrWhiteSpace(Schema))
{
throw new InvalidOperationException("Platform storage schema must be specified.");
}
}
}

View File

@@ -0,0 +1,161 @@
using System;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Endpoints;
using StellaOps.Platform.WebService.Options;
using StellaOps.Platform.WebService.Services;
using StellaOps.Router.AspNet;
using StellaOps.Telemetry.Core;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "PLATFORM_";
options.BindingSection = PlatformServiceOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddYamlFile("../etc/platform.yaml", optional: true);
configurationBuilder.AddYamlFile("platform.yaml", optional: true);
};
});
var bootstrapOptions = builder.Configuration.BindOptions<PlatformServiceOptions>(
PlatformServiceOptions.SectionName,
static (options, _) => options.Validate());
builder.Services.AddOptions<PlatformServiceOptions>()
.Bind(builder.Configuration.GetSection(PlatformServiceOptions.SectionName))
.Validate(options =>
{
options.Validate();
return true;
})
.ValidateOnStart();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
serviceName: "StellaOps.Platform",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString(),
configureMetrics: meterBuilder =>
{
meterBuilder.AddMeter("StellaOps.Platform.Aggregation");
});
builder.Services.AddTelemetryContextPropagation();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
resourceOptions.RequiredTenants.Clear();
foreach (var tenant in bootstrapOptions.Authority.RequiredTenants)
{
resourceOptions.RequiredTenants.Add(tenant);
}
resourceOptions.BypassNetworks.Clear();
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthRead, PlatformScopes.OpsHealth);
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthAdmin, PlatformScopes.OpsAdmin);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingRead, PlatformScopes.OnboardingRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesWrite, PlatformScopes.PreferencesWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.SearchRead, PlatformScopes.SearchRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.MetadataRead, PlatformScopes.MetadataRead);
});
builder.Services.AddSingleton<PlatformRequestContextResolver>();
builder.Services.AddSingleton<PlatformCache>();
builder.Services.AddSingleton<PlatformAggregationMetrics>();
builder.Services.AddSingleton<PlatformQuotaAlertStore>();
builder.Services.AddSingleton<PlatformQuotaService>();
builder.Services.AddSingleton<PlatformHealthService>();
builder.Services.AddSingleton<PlatformOnboardingStore>();
builder.Services.AddSingleton<PlatformOnboardingService>();
builder.Services.AddSingleton<PlatformPreferencesStore>();
builder.Services.AddSingleton<PlatformDashboardProfileStore>();
builder.Services.AddSingleton<PlatformPreferencesService>();
builder.Services.AddSingleton<PlatformSearchService>();
builder.Services.AddSingleton<PlatformMetadataService>();
var routerOptions = builder.Configuration.GetSection("Platform:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "platform",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
if (!string.Equals(bootstrapOptions.Storage.Driver, "memory", StringComparison.OrdinalIgnoreCase))
{
app.Logger.LogWarning("Platform storage driver {Driver} is not implemented; using in-memory stores.", bootstrapOptions.Storage.Driver);
}
app.UseStellaOpsTelemetryContext();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.MapPlatformEndpoints();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
.WithTags("Health")
.AllowAnonymous();
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithTags("Health")
.AllowAnonymous();
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.Run();
public partial class Program;

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformAggregationMetrics
{
private readonly Meter meter = new("StellaOps.Platform.Aggregation");
private readonly Histogram<double> latency;
private readonly Counter<long> errors;
private readonly Counter<long> cacheHits;
public PlatformAggregationMetrics()
{
latency = meter.CreateHistogram<double>(
"platform.aggregate.latency_ms",
unit: "ms",
description: "Platform aggregation latency in milliseconds.");
errors = meter.CreateCounter<long>(
"platform.aggregate.errors_total",
description: "Count of platform aggregation errors.");
cacheHits = meter.CreateCounter<long>(
"platform.aggregate.cache_hits_total",
description: "Count of platform aggregation cache hits.");
}
public AggregationScope Start(string operation)
{
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
return new AggregationScope(this, operation);
}
internal void RecordSuccess(string operation, double durationMs, bool cached)
{
latency.Record(durationMs, Tags(operation));
if (cached)
{
cacheHits.Add(1, Tags(operation));
}
}
internal void RecordFailure(string operation, double durationMs)
{
latency.Record(durationMs, Tags(operation));
errors.Add(1, Tags(operation));
}
private static KeyValuePair<string, object?>[] Tags(string operation)
=> new[] { new KeyValuePair<string, object?>("operation", operation) };
}
public sealed class AggregationScope : IDisposable
{
private readonly PlatformAggregationMetrics metrics;
private readonly string operation;
private readonly long startTimestamp;
private bool? success;
private bool cached;
internal AggregationScope(PlatformAggregationMetrics metrics, string operation)
{
this.metrics = metrics;
this.operation = operation;
startTimestamp = Stopwatch.GetTimestamp();
}
public void MarkSuccess(bool cached)
{
success = true;
this.cached = cached;
}
public void MarkFailure()
{
success = false;
}
public void Dispose()
{
var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds;
if (success == true)
{
metrics.RecordSuccess(operation, elapsedMs, cached);
return;
}
metrics.RecordFailure(operation, elapsedMs);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformCache
{
private readonly IMemoryCache cache;
private readonly TimeProvider timeProvider;
public PlatformCache(IMemoryCache cache, TimeProvider timeProvider)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<PlatformCacheResult<T>> GetOrCreateAsync<T>(
string cacheKey,
TimeSpan ttl,
Func<CancellationToken, Task<T>> factory,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
ArgumentNullException.ThrowIfNull(factory);
if (ttl <= TimeSpan.Zero)
{
var value = await factory(cancellationToken).ConfigureAwait(false);
return new PlatformCacheResult<T>(value, timeProvider.GetUtcNow(), cached: false, cacheTtlSeconds: 0);
}
if (cache.TryGetValue(cacheKey, out PlatformCacheEntry<T>? entry) && entry is not null)
{
return new PlatformCacheResult<T>(entry.Value, entry.DataAsOf, cached: true, cacheTtlSeconds: entry.CacheTtlSeconds);
}
var dataAsOf = timeProvider.GetUtcNow();
var payload = await factory(cancellationToken).ConfigureAwait(false);
var ttlSeconds = (int)Math.Max(0, ttl.TotalSeconds);
entry = new PlatformCacheEntry<T>(payload, dataAsOf, ttlSeconds);
cache.Set(cacheKey, entry, ttl);
return new PlatformCacheResult<T>(payload, dataAsOf, cached: false, cacheTtlSeconds: ttlSeconds);
}
}
internal sealed record PlatformCacheEntry<T>(
T Value,
DateTimeOffset DataAsOf,
int CacheTtlSeconds);
public sealed record PlatformCacheResult<T>(
T Value,
DateTimeOffset DataAsOf,
bool Cached,
int CacheTtlSeconds);

View File

@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformHealthService
{
private static readonly string[] ServiceNames =
{
"authority",
"gateway",
"orchestrator",
"policy",
"scanner",
"signals",
"notifier"
};
private readonly PlatformCache cache;
private readonly PlatformAggregationMetrics metrics;
private readonly TimeProvider timeProvider;
private readonly PlatformCacheOptions cacheOptions;
private readonly ILogger<PlatformHealthService> logger;
public PlatformHealthService(
PlatformCache cache,
PlatformAggregationMetrics metrics,
TimeProvider timeProvider,
IOptions<PlatformServiceOptions> options,
ILogger<PlatformHealthService> logger)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.cacheOptions = options?.Value.Cache ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformCacheResult<PlatformHealthSummary>> GetSummaryAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "health.summary",
cacheKey: $"platform:health:summary:{context.TenantId}",
ttlSeconds: cacheOptions.HealthSummarySeconds,
factory: ct => Task.FromResult(BuildSummary()),
cancellationToken: cancellationToken);
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformDependencyStatus>>> GetDependenciesAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "health.dependencies",
cacheKey: $"platform:health:dependencies:{context.TenantId}",
ttlSeconds: cacheOptions.HealthDependenciesSeconds,
factory: ct => Task.FromResult(BuildDependencies()),
cancellationToken: cancellationToken);
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformIncident>>> GetIncidentsAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "health.incidents",
cacheKey: $"platform:health:incidents:{context.TenantId}",
ttlSeconds: cacheOptions.HealthIncidentsSeconds,
factory: ct => Task.FromResult(BuildIncidents()),
cancellationToken: cancellationToken);
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformHealthMetric>>> GetMetricsAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "health.metrics",
cacheKey: $"platform:health:metrics:{context.TenantId}",
ttlSeconds: cacheOptions.HealthMetricsSeconds,
factory: ct => Task.FromResult(BuildMetrics()),
cancellationToken: cancellationToken);
}
private async Task<PlatformCacheResult<T>> GetCachedAsync<T>(
string operation,
string cacheKey,
int ttlSeconds,
Func<CancellationToken, Task<T>> factory,
CancellationToken cancellationToken)
{
using var scope = metrics.Start(operation);
try
{
var result = await cache.GetOrCreateAsync(
cacheKey,
TimeSpan.FromSeconds(ttlSeconds),
factory,
cancellationToken).ConfigureAwait(false);
scope.MarkSuccess(result.Cached);
if (result.Cached)
{
logger.LogDebug("Platform cache hit for {Operation}.", operation);
}
return result;
}
catch (Exception ex)
{
scope.MarkFailure();
logger.LogError(ex, "Platform aggregation failed for {Operation}.", operation);
throw;
}
}
private PlatformHealthSummary BuildSummary()
{
var now = timeProvider.GetUtcNow();
var services = ServiceNames
.Select((service, index) => new PlatformHealthServiceStatus(
service,
status: "healthy",
detail: null,
checkedAt: now,
latencyMs: 10 + (index * 2)))
.OrderBy(item => item.Service, StringComparer.Ordinal)
.ToArray();
return new PlatformHealthSummary(
Status: "healthy",
IncidentCount: 0,
Services: services);
}
private IReadOnlyList<PlatformDependencyStatus> BuildDependencies()
{
var now = timeProvider.GetUtcNow();
return ServiceNames
.Select(service => new PlatformDependencyStatus(
service,
status: "ready",
version: "unknown",
checkedAt: now,
message: null))
.OrderBy(item => item.Service, StringComparer.Ordinal)
.ToArray();
}
private IReadOnlyList<PlatformIncident> BuildIncidents()
{
return Array.Empty<PlatformIncident>();
}
private IReadOnlyList<PlatformHealthMetric> BuildMetrics()
{
var now = timeProvider.GetUtcNow();
var metrics = new[]
{
new PlatformHealthMetric(
Metric: "platform.aggregate.latency_ms",
Value: 12,
Unit: "ms",
Status: "ok",
Threshold: 250,
SampledAt: now),
new PlatformHealthMetric(
Metric: "platform.aggregate.errors_total",
Value: 0,
Unit: "count",
Status: "ok",
Threshold: 1,
SampledAt: now)
};
return metrics
.OrderBy(item => item.Metric, StringComparer.Ordinal)
.ToArray();
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformMetadataService
{
private readonly PlatformCache cache;
private readonly PlatformAggregationMetrics metrics;
private readonly PlatformCacheOptions cacheOptions;
private readonly PlatformMetadataOptions metadataOptions;
private readonly ILogger<PlatformMetadataService> logger;
public PlatformMetadataService(
PlatformCache cache,
PlatformAggregationMetrics metrics,
IOptions<PlatformServiceOptions> options,
ILogger<PlatformMetadataService> logger)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
this.cacheOptions = options?.Value.Cache ?? throw new ArgumentNullException(nameof(options));
this.metadataOptions = options.Value.Metadata;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformCacheResult<PlatformMetadata>> GetMetadataAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "metadata",
cacheKey: $"platform:metadata:{context.TenantId}",
ttlSeconds: cacheOptions.MetadataSeconds,
factory: ct => Task.FromResult(BuildMetadata()),
cancellationToken: cancellationToken);
}
private PlatformMetadata BuildMetadata()
{
var version = typeof(PlatformMetadataService).Assembly.GetName().Version?.ToString() ?? "1.0.0";
var capabilities = new[]
{
new PlatformCapability("health", "Aggregated platform health signals", true),
new PlatformCapability("quotas", "Cross-service quota aggregation", true),
new PlatformCapability("onboarding", "Tenant onboarding state", true),
new PlatformCapability("preferences", "Dashboard personalization", true),
new PlatformCapability("search", "Global search aggregation", true)
};
return new PlatformMetadata(
Service: "platform",
Version: version,
BuildVersion: metadataOptions.BuildVersion,
Environment: metadataOptions.Environment,
Region: metadataOptions.Region,
OfflineMode: metadataOptions.OfflineMode,
Capabilities: capabilities.OrderBy(cap => cap.Id, StringComparer.Ordinal).ToArray());
}
private async Task<PlatformCacheResult<PlatformMetadata>> GetCachedAsync(
string operation,
string cacheKey,
int ttlSeconds,
Func<CancellationToken, Task<PlatformMetadata>> factory,
CancellationToken cancellationToken)
{
using var scope = metrics.Start(operation);
try
{
var result = await cache.GetOrCreateAsync(
cacheKey,
TimeSpan.FromSeconds(ttlSeconds),
factory,
cancellationToken).ConfigureAwait(false);
scope.MarkSuccess(result.Cached);
if (result.Cached)
{
logger.LogDebug("Platform cache hit for {Operation}.", operation);
}
return result;
}
catch (Exception ex)
{
scope.MarkFailure();
logger.LogError(ex, "Platform metadata aggregation failed for {Operation}.", operation);
throw;
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Platform.WebService.Contracts;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformOnboardingService
{
private static readonly string[] DefaultSteps =
{
"connect-scanner",
"configure-policy",
"first-scan",
"review-findings",
"invite-team"
};
private readonly PlatformOnboardingStore store;
private readonly TimeProvider timeProvider;
private readonly ILogger<PlatformOnboardingService> logger;
public PlatformOnboardingService(
PlatformOnboardingStore store,
TimeProvider timeProvider,
ILogger<PlatformOnboardingService> logger)
{
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformOnboardingState> GetStatusAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var state = store.GetOrCreate(context.TenantId, context.ActorId, () => CreateDefaultState(context));
return Task.FromResult(state);
}
public Task<PlatformOnboardingState> CompleteStepAsync(
PlatformRequestContext context,
string step,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(step))
{
throw new InvalidOperationException("step is required.");
}
var normalizedStep = step.Trim().ToLowerInvariant();
var now = timeProvider.GetUtcNow();
var state = store.GetOrCreate(context.TenantId, context.ActorId, () => CreateDefaultState(context));
var steps = UpdateSteps(state.Steps, normalizedStep, "completed", now, context.ActorId, notes: null);
var updated = state with
{
Steps = steps,
Status = ResolveOverallStatus(steps),
UpdatedAt = now,
UpdatedBy = context.ActorId,
SkippedReason = state.SkippedReason
};
store.Upsert(context.TenantId, context.ActorId, updated);
logger.LogInformation("Completed onboarding step {Step} for tenant {TenantId}.", normalizedStep, context.TenantId);
return Task.FromResult(updated);
}
public Task<PlatformOnboardingState> SkipAsync(
PlatformRequestContext context,
string? reason,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var state = store.GetOrCreate(context.TenantId, context.ActorId, () => CreateDefaultState(context));
var notes = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
var steps = state.Steps
.Select(step => step.Status == "pending"
? step with { Status = "skipped", UpdatedAt = now, UpdatedBy = context.ActorId, Notes = notes }
: step)
.OrderBy(step => step.Step, StringComparer.Ordinal)
.ToArray();
var updated = state with
{
Steps = steps,
Status = "skipped",
UpdatedAt = now,
UpdatedBy = context.ActorId,
SkippedReason = notes
};
store.Upsert(context.TenantId, context.ActorId, updated);
logger.LogInformation("Skipped onboarding for tenant {TenantId}.", context.TenantId);
return Task.FromResult(updated);
}
public Task<PlatformTenantSetupStatus> GetTenantSetupStatusAsync(
string tenantId,
CancellationToken cancellationToken)
{
var states = store.ListByTenant(tenantId);
var completed = states.Count(state => state.Status == "completed");
var skipped = states.Count(state => state.Status == "skipped");
var pending = states.Count(state => state.Status == "in_progress");
var total = states.Count;
var response = new PlatformTenantSetupStatus(
TenantId: tenantId,
TotalUsers: total,
CompletedUsers: completed,
SkippedUsers: skipped,
PendingUsers: pending,
UpdatedAt: timeProvider.GetUtcNow());
return Task.FromResult(response);
}
private PlatformOnboardingState CreateDefaultState(PlatformRequestContext context)
{
var now = timeProvider.GetUtcNow();
var steps = DefaultSteps
.Select(step => new PlatformOnboardingStepStatus(step, "pending", null, null, null))
.OrderBy(step => step.Step, StringComparer.Ordinal)
.ToArray();
return new PlatformOnboardingState(
TenantId: context.TenantId,
ActorId: context.ActorId,
Status: "in_progress",
Steps: steps,
UpdatedAt: now,
UpdatedBy: context.ActorId,
SkippedReason: null);
}
private static IReadOnlyList<PlatformOnboardingStepStatus> UpdateSteps(
IReadOnlyList<PlatformOnboardingStepStatus> steps,
string target,
string status,
DateTimeOffset updatedAt,
string updatedBy,
string? notes)
{
return steps
.Select(step => string.Equals(step.Step, target, StringComparison.OrdinalIgnoreCase)
? step with { Status = status, UpdatedAt = updatedAt, UpdatedBy = updatedBy, Notes = notes }
: step)
.OrderBy(step => step.Step, StringComparer.Ordinal)
.ToArray();
}
private static string ResolveOverallStatus(IReadOnlyList<PlatformOnboardingStepStatus> steps)
{
if (steps.Any(step => step.Status == "pending"))
{
return "in_progress";
}
if (steps.Any(step => step.Status == "skipped"))
{
return "skipped";
}
return "completed";
}
}
public sealed class PlatformOnboardingStore
{
private readonly ConcurrentDictionary<PlatformUserKey, PlatformOnboardingState> states = new();
public PlatformOnboardingState GetOrCreate(string tenantId, string actorId, Func<PlatformOnboardingState> factory)
{
var key = PlatformUserKey.Create(tenantId, actorId);
return states.GetOrAdd(key, _ => factory());
}
public void Upsert(string tenantId, string actorId, PlatformOnboardingState state)
{
var key = PlatformUserKey.Create(tenantId, actorId);
states[key] = state;
}
public IReadOnlyList<PlatformOnboardingState> ListByTenant(string tenantId)
{
var normalized = tenantId.Trim().ToLowerInvariant();
return states
.Where(pair => string.Equals(pair.Key.TenantId, normalized, StringComparison.Ordinal))
.Select(pair => pair.Value)
.OrderBy(state => state.ActorId, StringComparer.Ordinal)
.ToArray();
}
}

View File

@@ -0,0 +1,254 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Platform.WebService.Contracts;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformPreferencesService
{
private static readonly JsonObject DefaultPreferences = new()
{
["layout"] = "default",
["widgets"] = new JsonArray(),
["filters"] = new JsonObject()
};
private readonly PlatformPreferencesStore store;
private readonly PlatformDashboardProfileStore profileStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<PlatformPreferencesService> logger;
public PlatformPreferencesService(
PlatformPreferencesStore store,
PlatformDashboardProfileStore profileStore,
TimeProvider timeProvider,
ILogger<PlatformPreferencesService> logger)
{
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformDashboardPreferences> GetPreferencesAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var preferences = store.GetOrCreate(context.TenantId, context.ActorId, () =>
{
var now = timeProvider.GetUtcNow();
return new PlatformDashboardPreferences(
TenantId: context.TenantId,
ActorId: context.ActorId,
Preferences: ClonePreferences(DefaultPreferences),
UpdatedAt: now,
UpdatedBy: context.ActorId);
});
return Task.FromResult(preferences with { Preferences = ClonePreferences(preferences.Preferences) });
}
public Task<PlatformDashboardPreferences> UpsertPreferencesAsync(
PlatformRequestContext context,
PlatformDashboardPreferencesRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var now = timeProvider.GetUtcNow();
var preferences = new PlatformDashboardPreferences(
TenantId: context.TenantId,
ActorId: context.ActorId,
Preferences: ClonePreferences(request.Preferences),
UpdatedAt: now,
UpdatedBy: context.ActorId);
store.Upsert(context.TenantId, context.ActorId, preferences);
logger.LogInformation("Updated dashboard preferences for tenant {TenantId}.", context.TenantId);
return Task.FromResult(preferences with { Preferences = ClonePreferences(preferences.Preferences) });
}
public Task<IReadOnlyList<PlatformDashboardProfile>> GetProfilesAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var profiles = profileStore.List(context.TenantId)
.OrderBy(profile => profile.ProfileId, StringComparer.Ordinal)
.ToArray();
return Task.FromResult<IReadOnlyList<PlatformDashboardProfile>>(profiles);
}
public Task<PlatformDashboardProfile?> GetProfileAsync(
PlatformRequestContext context,
string profileId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(profileId))
{
return Task.FromResult<PlatformDashboardProfile?>(null);
}
return Task.FromResult(profileStore.TryGet(context.TenantId, profileId.Trim(), out var profile)
? profile
: null);
}
public Task<PlatformDashboardProfile> CreateProfileAsync(
PlatformRequestContext context,
PlatformDashboardProfileRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var profileId = request.ProfileId?.Trim();
if (string.IsNullOrWhiteSpace(profileId))
{
throw new InvalidOperationException("profileId is required.");
}
if (profileStore.Exists(context.TenantId, profileId))
{
throw new InvalidOperationException("profileId already exists.");
}
var now = timeProvider.GetUtcNow();
var profile = new PlatformDashboardProfile(
ProfileId: profileId,
Name: request.Name?.Trim() ?? profileId,
Description: request.Description?.Trim(),
Preferences: ClonePreferences(request.Preferences),
UpdatedAt: now,
UpdatedBy: context.ActorId);
profileStore.Add(context.TenantId, profile);
logger.LogInformation("Created dashboard profile {ProfileId} for tenant {TenantId}.", profile.ProfileId, context.TenantId);
return Task.FromResult(profile with { Preferences = ClonePreferences(profile.Preferences) });
}
private static JsonObject ClonePreferences(JsonObject? source)
{
if (source is null)
{
return new JsonObject();
}
return (JsonObject)source.DeepClone();
}
}
public sealed class PlatformPreferencesStore
{
private readonly ConcurrentDictionary<PlatformUserKey, PlatformDashboardPreferences> preferences = new();
public PlatformDashboardPreferences GetOrCreate(
string tenantId,
string actorId,
Func<PlatformDashboardPreferences> factory)
{
var key = PlatformUserKey.Create(tenantId, actorId);
return preferences.GetOrAdd(key, _ => factory());
}
public void Upsert(string tenantId, string actorId, PlatformDashboardPreferences value)
{
var key = PlatformUserKey.Create(tenantId, actorId);
preferences[key] = value;
}
}
public sealed class PlatformDashboardProfileStore
{
private readonly ConcurrentDictionary<string, PlatformDashboardProfile> profiles = new(StringComparer.Ordinal);
private readonly IReadOnlyList<PlatformDashboardProfile> defaults;
public PlatformDashboardProfileStore()
{
var baseline = DateTimeOffset.UnixEpoch;
defaults = new[]
{
new PlatformDashboardProfile(
ProfileId: "default",
Name: "Default",
Description: "Baseline operational layout",
Preferences: new JsonObject { ["layout"] = "default" },
UpdatedAt: baseline,
UpdatedBy: "system"),
new PlatformDashboardProfile(
ProfileId: "incident",
Name: "Incident",
Description: "Incident command layout",
Preferences: new JsonObject { ["layout"] = "incident" },
UpdatedAt: baseline,
UpdatedBy: "system")
};
}
public IReadOnlyList<PlatformDashboardProfile> List(string tenantId)
{
var prefix = BuildKeyPrefix(tenantId);
var tenantProfiles = profiles
.Where(pair => pair.Key.StartsWith(prefix, StringComparison.Ordinal))
.Select(pair => CloneProfile(pair.Value))
.ToArray();
var baselineProfiles = defaults.Select(CloneProfile);
return baselineProfiles.Concat(tenantProfiles)
.OrderBy(profile => profile.ProfileId, StringComparer.Ordinal)
.ToArray();
}
public bool TryGet(string tenantId, string profileId, out PlatformDashboardProfile profile)
{
if (TryGetTenantProfile(tenantId, profileId, out profile))
{
profile = CloneProfile(profile);
return true;
}
var defaultProfile = defaults.FirstOrDefault(p => string.Equals(p.ProfileId, profileId, StringComparison.Ordinal));
if (defaultProfile is null)
{
profile = null!;
return false;
}
profile = CloneProfile(defaultProfile);
return true;
}
public bool Exists(string tenantId, string profileId)
{
return profiles.ContainsKey(BuildKey(tenantId, profileId));
}
public void Add(string tenantId, PlatformDashboardProfile profile)
{
profiles[BuildKey(tenantId, profile.ProfileId)] = profile;
}
private bool TryGetTenantProfile(string tenantId, string profileId, out PlatformDashboardProfile profile)
{
return profiles.TryGetValue(BuildKey(tenantId, profileId), out profile!);
}
private static string BuildKeyPrefix(string tenantId) => $"{tenantId.Trim().ToLowerInvariant()}::";
private static string BuildKey(string tenantId, string profileId)
=> $"{tenantId.Trim().ToLowerInvariant()}::{profileId}";
private static PlatformDashboardProfile CloneProfile(PlatformDashboardProfile profile)
{
var cloned = profile.Preferences is null ? new JsonObject() : (JsonObject)profile.Preferences.DeepClone();
return profile with { Preferences = cloned };
}
}

View File

@@ -0,0 +1,208 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformQuotaService
{
private static readonly PlatformQuotaDefinition[] Quotas =
{
new("gateway.requests", "tenant", "requests", 100000m, 23000m, "month", "gateway"),
new("orchestrator.jobs", "tenant", "jobs", 1000m, 120m, "day", "orchestrator"),
new("storage.evidence", "tenant", "gb", 5000m, 2400m, "month", "storage")
};
private readonly PlatformCache cache;
private readonly PlatformAggregationMetrics metrics;
private readonly TimeProvider timeProvider;
private readonly PlatformCacheOptions cacheOptions;
private readonly PlatformQuotaAlertStore alertStore;
private readonly ILogger<PlatformQuotaService> logger;
public PlatformQuotaService(
PlatformCache cache,
PlatformAggregationMetrics metrics,
TimeProvider timeProvider,
IOptions<PlatformServiceOptions> options,
PlatformQuotaAlertStore alertStore,
ILogger<PlatformQuotaService> logger)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.cacheOptions = options?.Value.Cache ?? throw new ArgumentNullException(nameof(options));
this.alertStore = alertStore ?? throw new ArgumentNullException(nameof(alertStore));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformQuotaUsage>>> GetSummaryAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "quota.summary",
cacheKey: $"platform:quota:summary:{context.TenantId}",
ttlSeconds: cacheOptions.QuotaSummarySeconds,
factory: ct => Task.FromResult(BuildQuotaUsage()),
cancellationToken: cancellationToken);
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformQuotaUsage>>> GetTenantAsync(
string tenantId,
CancellationToken cancellationToken)
{
return GetCachedAsync(
operation: "quota.tenant",
cacheKey: $"platform:quota:tenant:{tenantId}",
ttlSeconds: cacheOptions.QuotaTenantSeconds,
factory: ct => Task.FromResult(BuildQuotaUsage()),
cancellationToken: cancellationToken);
}
public Task<PlatformCacheResult<IReadOnlyList<PlatformQuotaAlert>>> GetAlertsAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var items = alertStore.List(context.TenantId);
return Task.FromResult(new PlatformCacheResult<IReadOnlyList<PlatformQuotaAlert>>(
items,
now,
cached: false,
cacheTtlSeconds: 0));
}
public Task<PlatformQuotaAlert> CreateAlertAsync(
PlatformRequestContext context,
PlatformQuotaAlertRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var quotaId = request.QuotaId?.Trim();
if (string.IsNullOrWhiteSpace(quotaId))
{
throw new InvalidOperationException("quotaId is required.");
}
var condition = request.Condition?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(condition))
{
throw new InvalidOperationException("condition is required.");
}
var severity = request.Severity?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(severity))
{
throw new InvalidOperationException("severity is required.");
}
var alert = new PlatformQuotaAlert(
AlertId: $"alert-{Guid.NewGuid():N}",
QuotaId: quotaId,
Severity: severity,
Threshold: request.Threshold,
Condition: condition,
CreatedAt: timeProvider.GetUtcNow(),
CreatedBy: context.ActorId);
alertStore.Add(context.TenantId, alert);
logger.LogInformation("Created quota alert {AlertId} for tenant {TenantId}.", alert.AlertId, context.TenantId);
return Task.FromResult(alert);
}
private async Task<PlatformCacheResult<IReadOnlyList<PlatformQuotaUsage>>> GetCachedAsync(
string operation,
string cacheKey,
int ttlSeconds,
Func<CancellationToken, Task<IReadOnlyList<PlatformQuotaUsage>>> factory,
CancellationToken cancellationToken)
{
using var scope = metrics.Start(operation);
try
{
var result = await cache.GetOrCreateAsync(
cacheKey,
TimeSpan.FromSeconds(ttlSeconds),
factory,
cancellationToken).ConfigureAwait(false);
scope.MarkSuccess(result.Cached);
if (result.Cached)
{
logger.LogDebug("Platform cache hit for {Operation}.", operation);
}
return result;
}
catch (Exception ex)
{
scope.MarkFailure();
logger.LogError(ex, "Platform quota aggregation failed for {Operation}.", operation);
throw;
}
}
private IReadOnlyList<PlatformQuotaUsage> BuildQuotaUsage()
{
var measuredAt = timeProvider.GetUtcNow();
return Quotas
.Select(quota => new PlatformQuotaUsage(
QuotaId: quota.QuotaId,
Scope: quota.Scope,
Unit: quota.Unit,
Limit: quota.Limit,
Used: quota.Used,
Remaining: Math.Max(0m, quota.Limit - quota.Used),
Period: quota.Period,
Source: quota.Source,
MeasuredAt: measuredAt))
.OrderBy(item => item.QuotaId, StringComparer.Ordinal)
.ToArray();
}
private sealed record PlatformQuotaDefinition(
string QuotaId,
string Scope,
string Unit,
decimal Limit,
decimal Used,
string Period,
string Source);
}
public sealed class PlatformQuotaAlertStore
{
private readonly ConcurrentDictionary<string, PlatformQuotaAlert> alerts = new(StringComparer.Ordinal);
public IReadOnlyList<PlatformQuotaAlert> List(string tenantId)
{
var prefix = BuildKeyPrefix(tenantId);
return alerts
.Where(pair => pair.Key.StartsWith(prefix, StringComparison.Ordinal))
.Select(pair => pair.Value)
.OrderBy(alert => alert.AlertId, StringComparer.Ordinal)
.ToArray();
}
public void Add(string tenantId, PlatformQuotaAlert alert)
{
alerts[BuildKey(tenantId, alert.AlertId)] = alert;
}
private static string BuildKeyPrefix(string tenantId) => $"{tenantId.Trim().ToLowerInvariant()}::";
private static string BuildKey(string tenantId, string alertId)
=> $"{tenantId.Trim().ToLowerInvariant()}::{alertId}";
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Platform.WebService.Services;
public sealed record PlatformRequestContext(
string TenantId,
string ActorId,
string? ProjectId);

View File

@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformRequestContextResolver
{
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string ProjectHeader = "X-Stella-Project";
private const string ActorHeader = "X-StellaOps-Actor";
public bool TryResolve(HttpContext context, out PlatformRequestContext? requestContext, out string? error)
{
requestContext = null;
error = null;
if (!TryResolveTenant(context, out var tenantId))
{
error = "tenant_missing";
return false;
}
var actorId = ResolveActor(context);
var projectId = ResolveProject(context);
requestContext = new PlatformRequestContext(tenantId, actorId, projectId);
return true;
}
private static bool TryResolveTenant(HttpContext context, out string tenantId)
{
tenantId = string.Empty;
var claimTenant = context.User.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenantId = claimTenant.Trim().ToLowerInvariant();
return true;
}
if (TryResolveHeader(context, StellaOpsHttpHeaderNames.Tenant, out tenantId))
{
tenantId = tenantId.ToLowerInvariant();
return true;
}
if (TryResolveHeader(context, LegacyTenantHeader, out tenantId))
{
tenantId = tenantId.ToLowerInvariant();
return true;
}
return false;
}
private static string ResolveActor(HttpContext context)
{
var subject = context.User.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.Trim();
}
var clientId = context.User.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
return clientId.Trim();
}
if (TryResolveHeader(context, ActorHeader, out var actor))
{
return actor;
}
return context.User.Identity?.Name?.Trim() ?? "anonymous";
}
private static string? ResolveProject(HttpContext context)
{
var projectClaim = context.User.FindFirstValue(StellaOpsClaimTypes.Project);
if (!string.IsNullOrWhiteSpace(projectClaim))
{
return projectClaim.Trim();
}
if (TryResolveHeader(context, ProjectHeader, out var project))
{
return project;
}
return null;
}
private static bool TryResolveHeader(HttpContext context, string headerName, out string value)
{
value = string.Empty;
if (!context.Request.Headers.TryGetValue(headerName, out var values))
{
return false;
}
var raw = values.ToString();
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
value = raw.Trim();
return true;
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
namespace StellaOps.Platform.WebService.Services;
public sealed class PlatformSearchService
{
private static readonly PlatformSearchItem[] Catalog =
{
new("scan-2025-0001", "scan", "Scan: api-service", "Latest scan for api-service", "scanner", "/scans/scan-2025-0001", 0.92, DateTimeOffset.UnixEpoch),
new("policy-ops-baseline", "policy", "Policy: Ops Baseline", "Baseline policy pack", "policy", "/policy/policy-ops-baseline", 0.85, DateTimeOffset.UnixEpoch),
new("finding-cve-2025-1001", "finding", "CVE-2025-1001", "Critical finding in payments", "findings", "/findings/cve-2025-1001", 0.88, DateTimeOffset.UnixEpoch),
new("pack-offline-kit", "pack", "Pack: Offline Kit", "Offline kit export bundle", "orchestrator", "/packs/offline-kit", 0.77, DateTimeOffset.UnixEpoch),
new("tenant-acme", "tenant", "Tenant: acme", "Tenant catalog entry", "authority", "/tenants/acme", 0.65, DateTimeOffset.UnixEpoch)
};
private readonly PlatformCache cache;
private readonly PlatformAggregationMetrics metrics;
private readonly TimeProvider timeProvider;
private readonly PlatformSearchOptions searchOptions;
private readonly PlatformCacheOptions cacheOptions;
private readonly ILogger<PlatformSearchService> logger;
public PlatformSearchService(
PlatformCache cache,
PlatformAggregationMetrics metrics,
TimeProvider timeProvider,
IOptions<PlatformServiceOptions> options,
ILogger<PlatformSearchService> logger)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.searchOptions = options?.Value.Search ?? throw new ArgumentNullException(nameof(options));
this.cacheOptions = options.Value.Cache;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<PlatformCacheResult<PlatformSearchResult>> SearchAsync(
PlatformRequestContext context,
string? query,
IReadOnlyList<string>? sources,
int? limit,
int? offset,
CancellationToken cancellationToken)
{
var normalizedQuery = string.IsNullOrWhiteSpace(query) ? null : query.Trim();
var normalizedSources = NormalizeSources(sources);
var boundedLimit = NormalizeLimit(limit);
var boundedOffset = Math.Max(0, offset ?? 0);
var cacheKey = BuildCacheKey(context, normalizedQuery, normalizedSources, boundedLimit, boundedOffset);
return GetCachedAsync(
operation: "search.query",
cacheKey: cacheKey,
ttlSeconds: cacheOptions.SearchSeconds,
factory: ct => Task.FromResult(BuildSearchResult(normalizedQuery, normalizedSources, boundedLimit, boundedOffset)),
cancellationToken: cancellationToken);
}
private PlatformSearchResult BuildSearchResult(
string? query,
IReadOnlyList<string> sources,
int limit,
int offset)
{
var filtered = Catalog.AsEnumerable();
if (!string.IsNullOrWhiteSpace(query))
{
filtered = filtered.Where(item =>
item.EntityId.Contains(query!, StringComparison.OrdinalIgnoreCase) ||
item.Title.Contains(query!, StringComparison.OrdinalIgnoreCase) ||
item.Summary.Contains(query!, StringComparison.OrdinalIgnoreCase));
}
if (sources.Count > 0)
{
filtered = filtered.Where(item => sources.Contains(item.Source, StringComparer.OrdinalIgnoreCase));
}
var ordered = filtered
.OrderByDescending(item => item.Score)
.ThenBy(item => item.EntityId, StringComparer.Ordinal)
.ToArray();
var total = ordered.Length;
var now = timeProvider.GetUtcNow();
var items = ordered
.Skip(offset)
.Take(limit)
.Select(item => item with { UpdatedAt = now })
.ToArray();
return new PlatformSearchResult(items, total, limit, offset, query);
}
private async Task<PlatformCacheResult<PlatformSearchResult>> GetCachedAsync(
string operation,
string cacheKey,
int ttlSeconds,
Func<CancellationToken, Task<PlatformSearchResult>> factory,
CancellationToken cancellationToken)
{
using var scope = metrics.Start(operation);
try
{
var result = await cache.GetOrCreateAsync(
cacheKey,
TimeSpan.FromSeconds(ttlSeconds),
factory,
cancellationToken).ConfigureAwait(false);
scope.MarkSuccess(result.Cached);
if (result.Cached)
{
logger.LogDebug("Platform cache hit for {Operation}.", operation);
}
return result;
}
catch (Exception ex)
{
scope.MarkFailure();
logger.LogError(ex, "Platform search failed for {Operation}.", operation);
throw;
}
}
private int NormalizeLimit(int? limit)
{
if (!limit.HasValue)
{
return searchOptions.DefaultLimit;
}
return Math.Clamp(limit.Value, 1, searchOptions.MaxLimit);
}
private static IReadOnlyList<string> NormalizeSources(IReadOnlyList<string>? sources)
{
if (sources is null || sources.Count == 0)
{
return Array.Empty<string>();
}
return sources
.Select(source => source?.Trim())
.Where(source => !string.IsNullOrWhiteSpace(source))
.Select(source => source!.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(source => source, StringComparer.Ordinal)
.ToArray();
}
private static string BuildCacheKey(
PlatformRequestContext context,
string? query,
IReadOnlyList<string> sources,
int limit,
int offset)
{
var sourceKey = sources.Count == 0 ? "all" : string.Join("|", sources);
var queryKey = string.IsNullOrWhiteSpace(query) ? "*" : query;
return $"platform:search:{context.TenantId}:{sourceKey}:{queryKey}:{limit}:{offset}";
}
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Platform.WebService.Services;
public readonly record struct PlatformUserKey(
string TenantId,
string ActorId)
{
public static PlatformUserKey Create(string tenantId, string actorId)
{
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
var normalizedActor = actorId.Trim();
return new PlatformUserKey(normalizedTenant, normalizedActor);
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using System;
using System.Linq;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class HealthEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public HealthEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Summary_UsesCacheAndStableDataAsOf()
{
var tenantId = $"tenant-health-{Guid.NewGuid():N}";
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
var first = await client.GetFromJsonAsync<PlatformItemResponse<PlatformHealthSummary>>("/api/v1/platform/health/summary");
Assert.NotNull(first);
Assert.False(first!.Cached);
var second = await client.GetFromJsonAsync<PlatformItemResponse<PlatformHealthSummary>>("/api/v1/platform/health/summary");
Assert.NotNull(second);
Assert.True(second!.Cached);
Assert.Equal(first.DataAsOf, second.DataAsOf);
Assert.True(first.Item.Services.Select(service => service.Service)
.SequenceEqual(second.Item.Services.Select(service => service.Service)));
}
}

View File

@@ -0,0 +1,32 @@
using System.Linq;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class MetadataEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public MetadataEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Metadata_ReturnsCapabilitiesInStableOrder()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-metadata");
var response = await client.GetFromJsonAsync<PlatformItemResponse<PlatformMetadata>>(
"/api/v1/platform/metadata");
Assert.NotNull(response);
var ids = response!.Item.Capabilities.Select(cap => cap.Id).ToArray();
Assert.Equal(new[] { "health", "onboarding", "preferences", "quotas", "search" }, ids);
}
}

View File

@@ -0,0 +1,40 @@
using System.Linq;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class OnboardingEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public OnboardingEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Onboarding_CompleteStepUpdatesStatus()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-onboarding");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-onboarding");
var response = await client.PostAsync("/api/v1/platform/onboarding/complete/connect-scanner", null);
response.EnsureSuccessStatusCode();
var state = await response.Content.ReadFromJsonAsync<PlatformOnboardingState>();
Assert.NotNull(state);
var step = state!.Steps.FirstOrDefault(item => item.Step == "connect-scanner");
Assert.NotNull(step);
Assert.Equal("completed", step!.Status);
Assert.Equal("actor-onboarding", step.UpdatedBy);
Assert.Equal(
state.Steps.OrderBy(item => item.Step, System.StringComparer.Ordinal).Select(item => item.Step),
state.Steps.Select(item => item.Step));
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PlatformWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Platform:Authority:Issuer", "https://authority.local");
builder.UseSetting("Platform:Authority:RequireHttpsMetadata", "false");
builder.UseSetting("Platform:Authority:BypassNetworks:0", "127.0.0.1/32");
builder.UseSetting("Platform:Authority:BypassNetworks:1", "::1/128");
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
});
}
}

View File

@@ -0,0 +1,47 @@
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PreferencesEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public PreferencesEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Preferences_RoundTripUpdatesLayout()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
var request = new PlatformDashboardPreferencesRequest(new JsonObject
{
["layout"] = "incident",
["widgets"] = new JsonArray("health", "quota"),
["filters"] = new JsonObject { ["scope"] = "tenant" }
});
var updateResponse = await client.PutAsJsonAsync("/api/v1/platform/preferences/dashboard", request);
updateResponse.EnsureSuccessStatusCode();
var updated = await client.GetFromJsonAsync<PlatformDashboardPreferences>("/api/v1/platform/preferences/dashboard");
Assert.NotNull(updated);
Assert.Equal("tenant-preferences", updated!.TenantId);
Assert.Equal("actor-preferences", updated.ActorId);
Assert.Equal("incident", updated.Preferences["layout"]?.GetValue<string>());
var widgets = updated.Preferences["widgets"] as JsonArray;
Assert.NotNull(widgets);
Assert.Equal(new[] { "health", "quota" }, widgets!.Select(widget => widget!.GetValue<string>()).ToArray());
}
}

View File

@@ -0,0 +1,35 @@
using System.Linq;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public QuotaEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Quotas_ReturnDeterministicOrder()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas");
var response = await client.GetFromJsonAsync<PlatformListResponse<PlatformQuotaUsage>>(
"/api/v1/platform/quotas/summary");
Assert.NotNull(response);
var items = response!.Items.ToArray();
Assert.Equal(
new[] { "gateway.requests", "orchestrator.jobs", "storage.evidence" },
items.Select(item => item.QuotaId).ToArray());
Assert.Equal(77000m, items[0].Remaining);
}
}

View File

@@ -0,0 +1,41 @@
using System.Linq;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class SearchEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public SearchEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Search_ReturnsDeterministicOrder()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-search");
var response = await client.GetFromJsonAsync<PlatformListResponse<PlatformSearchItem>>(
"/api/v1/platform/search?limit=5");
Assert.NotNull(response);
var items = response!.Items.Select(item => item.EntityId).ToArray();
Assert.Equal(
new[]
{
"scan-2025-0001",
"finding-cve-2025-1001",
"policy-ops-baseline",
"pack-offline-kit",
"tenant-acme"
},
items);
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Platform.WebService\StellaOps.Platform.WebService.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>