Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
36
src/Platform/AGENTS.md
Normal file
36
src/Platform/AGENTS.md
Normal 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.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src/Platform/StellaOps.Platform.WebService/Program.cs
Normal file
161
src/Platform/StellaOps.Platform.WebService/Program.cs
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed record PlatformRequestContext(
|
||||
string TenantId,
|
||||
string ActorId,
|
||||
string? ProjectId);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user