389 lines
17 KiB
C#
389 lines
17 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
|
|
namespace StellaOps.Policy.Gateway.Endpoints;
|
|
|
|
public static class GovernanceCompatibilityEndpoints
|
|
{
|
|
private static readonly ConcurrentDictionary<string, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
|
|
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
|
|
{
|
|
var governance = app.MapGroup("/api/v1/governance")
|
|
.WithTags("Governance Compatibility")
|
|
.RequireTenant();
|
|
|
|
governance.MapGet("/trust-weights", (
|
|
HttpContext context,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
return Results.Ok(state.ToResponse());
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
|
|
governance.MapPut("/trust-weights/{weightId}", (
|
|
HttpContext context,
|
|
string weightId,
|
|
[FromBody] TrustWeightWriteModel request,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
var updated = state.Upsert(weightId, request, timeProvider, StellaOpsTenantResolver.ResolveActor(context));
|
|
return Results.Ok(updated);
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
|
|
|
governance.MapDelete("/trust-weights/{weightId}", (
|
|
HttpContext context,
|
|
string weightId,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
state.Delete(weightId, timeProvider);
|
|
return Results.NoContent();
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
|
|
|
governance.MapPost("/trust-weights/preview-impact", (
|
|
[FromBody] TrustWeightPreviewRequest request) =>
|
|
{
|
|
var weights = request.Weights ?? [];
|
|
var severityChanges = weights.Count(static weight => weight.Weight >= 1.2m);
|
|
var decisionChanges = weights.Count(static weight => weight.Active != false);
|
|
|
|
var payload = new
|
|
{
|
|
affectedVulnerabilities = Math.Max(3, weights.Count * 9),
|
|
severityChanges,
|
|
decisionChanges,
|
|
sampleAffected = weights.Take(3).Select((weight, index) => new
|
|
{
|
|
findingId = $"finding-{index + 1:000}",
|
|
componentPurl = $"pkg:oci/{weight.IssuerId ?? "stellaops"}/runtime-{index + 1}@sha256:{(index + 1).ToString("D4")}",
|
|
advisoryId = $"CVE-2026-{1400 + index}",
|
|
currentSeverity = index == 0 ? "high" : "medium",
|
|
projectedSeverity = weight.Weight >= 1.2m ? "critical" : "high",
|
|
currentDecision = "warn",
|
|
projectedDecision = weight.Active != false ? "deny" : "warn"
|
|
}).ToArray(),
|
|
severityTransitions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["medium->high"] = Math.Max(1, severityChanges),
|
|
["high->critical"] = Math.Max(1, severityChanges / 2)
|
|
}
|
|
};
|
|
|
|
return Results.Ok(payload);
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
|
|
|
governance.MapGet("/staleness/config", (
|
|
HttpContext context,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
return Results.Ok(state.ToResponse());
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
|
|
governance.MapPut("/staleness/config/{dataType}", (
|
|
HttpContext context,
|
|
string dataType,
|
|
[FromBody] StalenessConfigWriteModel request,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
var updated = state.Upsert(dataType, request, timeProvider);
|
|
return Results.Ok(updated);
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
|
|
|
governance.MapGet("/staleness/status", (
|
|
HttpContext context,
|
|
[FromQuery] string? projectId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var scope = ResolveScope(context, projectId);
|
|
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
|
|
return Results.Ok(state.BuildStatus());
|
|
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
}
|
|
|
|
private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
|
|
{
|
|
if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error))
|
|
{
|
|
throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}");
|
|
}
|
|
|
|
var scopedProject = string.IsNullOrWhiteSpace(projectId)
|
|
? StellaOpsTenantResolver.ResolveProject(context)
|
|
: projectId.Trim();
|
|
|
|
return new GovernanceScope(
|
|
tenantId,
|
|
string.IsNullOrWhiteSpace(scopedProject) ? null : scopedProject,
|
|
string.IsNullOrWhiteSpace(scopedProject) ? tenantId : $"{tenantId}:{scopedProject}");
|
|
}
|
|
|
|
private static TrustWeightConfigState CreateDefaultTrustWeightState(string tenantId, string? projectId, TimeProvider timeProvider)
|
|
{
|
|
var now = timeProvider.GetUtcNow().ToString("O");
|
|
return new TrustWeightConfigState(
|
|
tenantId,
|
|
projectId,
|
|
now,
|
|
"\"trust-weights-v1\"",
|
|
[
|
|
new TrustWeightRecord("tw-001", "cisa", "CISA", "cisa", 1.50m, 1, true, "Government authoritative source", now, "system"),
|
|
new TrustWeightRecord("tw-002", "nist", "NIST NVD", "nist", 1.30m, 2, true, "Primary CVE source", now, "system"),
|
|
new TrustWeightRecord("tw-003", "vendor-redhat", "Red Hat", "vendor", 1.20m, 3, true, "Trusted vendor feed", now, "system")
|
|
]);
|
|
}
|
|
|
|
private static StalenessConfigState CreateDefaultStalenessState(string tenantId, string? projectId, TimeProvider timeProvider)
|
|
{
|
|
var now = timeProvider.GetUtcNow().ToString("O");
|
|
return new StalenessConfigState(
|
|
tenantId,
|
|
projectId,
|
|
now,
|
|
"\"staleness-v1\"",
|
|
[
|
|
new StalenessConfigRecord("sbom", BuildThresholds(7, 14, 30, 45), true, 12),
|
|
new StalenessConfigRecord("vulnerability_data", BuildThresholds(1, 3, 7, 14), true, 6),
|
|
new StalenessConfigRecord("vex_statements", BuildThresholds(3, 7, 14, 21), true, 12),
|
|
new StalenessConfigRecord("policy", BuildThresholds(14, 30, 45, 60), false, 24),
|
|
new StalenessConfigRecord("attestation", BuildThresholds(7, 14, 21, 30), true, 8),
|
|
new StalenessConfigRecord("scan_result", BuildThresholds(1, 2, 5, 10), true, 4)
|
|
]);
|
|
}
|
|
|
|
private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
|
|
[
|
|
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
|
|
new("aging", aging, "medium", [new StalenessActionRecord("notify", "Approaching review window.")]),
|
|
new("stale", stale, "high", [new StalenessActionRecord("flag_review", "Operator review required.")]),
|
|
new("expired", expired, "critical", [new StalenessActionRecord("block", "Fresh data required before continue.")])
|
|
];
|
|
|
|
private sealed record GovernanceScope(string TenantId, string? ProjectId, string Key);
|
|
|
|
private sealed class TrustWeightConfigState(
|
|
string tenantId,
|
|
string? projectId,
|
|
string modifiedAt,
|
|
string etag,
|
|
List<TrustWeightRecord> weights)
|
|
{
|
|
public string TenantId { get; private set; } = tenantId;
|
|
public string? ProjectId { get; private set; } = projectId;
|
|
public string ModifiedAt { get; private set; } = modifiedAt;
|
|
public string Etag { get; private set; } = etag;
|
|
public List<TrustWeightRecord> Weights { get; } = weights;
|
|
|
|
public object ToResponse() => new
|
|
{
|
|
tenantId = TenantId,
|
|
projectId = ProjectId,
|
|
weights = Weights.OrderBy(static weight => weight.Priority).ThenBy(static weight => weight.IssuerName, StringComparer.OrdinalIgnoreCase),
|
|
defaultWeight = 1.0m,
|
|
modifiedAt = ModifiedAt,
|
|
etag = Etag
|
|
};
|
|
|
|
public object Upsert(string routeWeightId, TrustWeightWriteModel request, TimeProvider timeProvider, string actor)
|
|
{
|
|
var effectiveId = string.IsNullOrWhiteSpace(request.Id) ? routeWeightId : request.Id.Trim();
|
|
var index = Weights.FindIndex(weight => string.Equals(weight.Id, effectiveId, StringComparison.OrdinalIgnoreCase));
|
|
var existing = index >= 0 ? Weights[index] : null;
|
|
var now = timeProvider.GetUtcNow().ToString("O");
|
|
var updated = new TrustWeightRecord(
|
|
effectiveId,
|
|
request.IssuerId?.Trim() ?? existing?.IssuerId ?? effectiveId,
|
|
request.IssuerName?.Trim() ?? existing?.IssuerName ?? effectiveId,
|
|
NormalizeSource(request.Source ?? existing?.Source),
|
|
request.Weight ?? existing?.Weight ?? 1.0m,
|
|
request.Priority ?? existing?.Priority ?? Weights.Count + 1,
|
|
request.Active ?? existing?.Active ?? true,
|
|
request.Reason?.Trim() ?? existing?.Reason,
|
|
now,
|
|
actor);
|
|
|
|
if (index >= 0)
|
|
{
|
|
Weights[index] = updated;
|
|
}
|
|
else
|
|
{
|
|
Weights.Add(updated);
|
|
}
|
|
|
|
ModifiedAt = now;
|
|
Etag = $"\"trust-weights-{Weights.Count}-{now}\"";
|
|
return updated;
|
|
}
|
|
|
|
public void Delete(string weightId, TimeProvider timeProvider)
|
|
{
|
|
Weights.RemoveAll(weight => string.Equals(weight.Id, weightId, StringComparison.OrdinalIgnoreCase));
|
|
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
|
|
Etag = $"\"trust-weights-{Weights.Count}-{ModifiedAt}\"";
|
|
}
|
|
}
|
|
|
|
private sealed class StalenessConfigState(
|
|
string tenantId,
|
|
string? projectId,
|
|
string modifiedAt,
|
|
string etag,
|
|
List<StalenessConfigRecord> configs)
|
|
{
|
|
public string TenantId { get; private set; } = tenantId;
|
|
public string? ProjectId { get; private set; } = projectId;
|
|
public string ModifiedAt { get; private set; } = modifiedAt;
|
|
public string Etag { get; private set; } = etag;
|
|
public List<StalenessConfigRecord> Configs { get; } = configs;
|
|
|
|
public object ToResponse() => new
|
|
{
|
|
tenantId = TenantId,
|
|
projectId = ProjectId,
|
|
configs = Configs.OrderBy(static config => config.DataType, StringComparer.OrdinalIgnoreCase),
|
|
modifiedAt = ModifiedAt,
|
|
etag = Etag
|
|
};
|
|
|
|
public object Upsert(string dataType, StalenessConfigWriteModel request, TimeProvider timeProvider)
|
|
{
|
|
var effectiveType = string.IsNullOrWhiteSpace(dataType) ? request.DataType?.Trim() ?? "sbom" : dataType.Trim();
|
|
var thresholds = request.Thresholds?.Count > 0
|
|
? request.Thresholds.Select(threshold => new StalenessThresholdRecord(
|
|
NormalizeLevel(threshold.Level),
|
|
threshold.AgeDays,
|
|
NormalizeSeverity(threshold.Severity),
|
|
threshold.Actions?.Select(action => new StalenessActionRecord(NormalizeActionType(action.Type), action.Message, action.Channels)).ToList() ?? [])).ToList()
|
|
: BuildThresholds(7, 14, 30, 45);
|
|
|
|
var updated = new StalenessConfigRecord(
|
|
effectiveType,
|
|
thresholds,
|
|
request.Enabled ?? true,
|
|
request.GracePeriodHours ?? 12);
|
|
|
|
var index = Configs.FindIndex(config => string.Equals(config.DataType, effectiveType, StringComparison.OrdinalIgnoreCase));
|
|
if (index >= 0)
|
|
{
|
|
Configs[index] = updated;
|
|
}
|
|
else
|
|
{
|
|
Configs.Add(updated);
|
|
}
|
|
|
|
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
|
|
Etag = $"\"staleness-{Configs.Count}-{ModifiedAt}\"";
|
|
return updated;
|
|
}
|
|
|
|
public object[] BuildStatus() =>
|
|
Configs.Select((config, index) => new
|
|
{
|
|
dataType = config.DataType,
|
|
itemId = $"{config.DataType}-asset-{index + 1}",
|
|
itemName = $"{config.DataType.Replace('_', ' ')} snapshot {index + 1}",
|
|
lastUpdatedAt = DateTimeOffset.Parse(ModifiedAt).AddDays(-(index + 1) * 3).ToString("O"),
|
|
ageDays = (index + 1) * 3,
|
|
level = index == 0 ? "fresh" : index == 1 ? "aging" : index == 2 ? "stale" : "expired",
|
|
blocked = index >= 2 && config.Enabled
|
|
}).ToArray();
|
|
}
|
|
|
|
private static string NormalizeSource(string? source) =>
|
|
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
|
|
|
|
private static string NormalizeSeverity(string? severity) =>
|
|
string.IsNullOrWhiteSpace(severity) ? "medium" : severity.Trim().ToLowerInvariant();
|
|
|
|
private static string NormalizeLevel(string? level) =>
|
|
string.IsNullOrWhiteSpace(level) ? "fresh" : level.Trim().ToLowerInvariant();
|
|
|
|
private static string NormalizeActionType(string? actionType) =>
|
|
string.IsNullOrWhiteSpace(actionType) ? "warn" : actionType.Trim().ToLowerInvariant();
|
|
|
|
private sealed record TrustWeightRecord(
|
|
string Id,
|
|
string IssuerId,
|
|
string IssuerName,
|
|
string Source,
|
|
decimal Weight,
|
|
int Priority,
|
|
bool Active,
|
|
string? Reason,
|
|
string ModifiedAt,
|
|
string ModifiedBy);
|
|
|
|
private sealed record StalenessConfigRecord(
|
|
string DataType,
|
|
List<StalenessThresholdRecord> Thresholds,
|
|
bool Enabled,
|
|
int GracePeriodHours);
|
|
|
|
private sealed record StalenessThresholdRecord(
|
|
string Level,
|
|
int AgeDays,
|
|
string Severity,
|
|
List<StalenessActionRecord> Actions);
|
|
|
|
private sealed record StalenessActionRecord(
|
|
string Type,
|
|
string? Message = null,
|
|
string[]? Channels = null);
|
|
}
|
|
|
|
public sealed record TrustWeightWriteModel
|
|
{
|
|
public string? Id { get; init; }
|
|
public string? IssuerId { get; init; }
|
|
public string? IssuerName { get; init; }
|
|
public string? Source { get; init; }
|
|
public decimal? Weight { get; init; }
|
|
public int? Priority { get; init; }
|
|
public bool? Active { get; init; }
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
public sealed record TrustWeightPreviewRequest(IReadOnlyList<TrustWeightWriteModel>? Weights);
|
|
|
|
public sealed record StalenessConfigWriteModel
|
|
{
|
|
public string? DataType { get; init; }
|
|
public IReadOnlyList<StalenessThresholdWriteModel>? Thresholds { get; init; }
|
|
public bool? Enabled { get; init; }
|
|
public int? GracePeriodHours { get; init; }
|
|
}
|
|
|
|
public sealed record StalenessThresholdWriteModel
|
|
{
|
|
public string? Level { get; init; }
|
|
public int AgeDays { get; init; }
|
|
public string? Severity { get; init; }
|
|
public IReadOnlyList<StalenessActionWriteModel>? Actions { get; init; }
|
|
}
|
|
|
|
public sealed record StalenessActionWriteModel
|
|
{
|
|
public string? Type { get; init; }
|
|
public string? Message { get; init; }
|
|
public string[]? Channels { get; init; }
|
|
}
|