up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -4,48 +4,48 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Concelier.Core.Attestation;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record AdvisoryIngestRequest(
AdvisorySourceRequest Source,
AdvisoryUpstreamRequest Upstream,
AdvisoryContentRequest Content,
AdvisoryIdentifiersRequest Identifiers,
AdvisoryLinksetRequest? Linkset);
public sealed record AdvisorySourceRequest(
[property: JsonPropertyName("vendor")] string Vendor,
[property: JsonPropertyName("connector")] string Connector,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("stream")] string? Stream);
public sealed record AdvisoryUpstreamRequest(
[property: JsonPropertyName("upstreamId")] string UpstreamId,
[property: JsonPropertyName("documentVersion")] string? DocumentVersion,
[property: JsonPropertyName("retrievedAt")] DateTimeOffset? RetrievedAt,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("signature")] AdvisorySignatureRequest Signature,
[property: JsonPropertyName("provenance")] IDictionary<string, string>? Provenance);
public sealed record AdvisorySignatureRequest(
[property: JsonPropertyName("present")] bool Present,
[property: JsonPropertyName("format")] string? Format,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("sig")] string? Signature,
[property: JsonPropertyName("certificate")] string? Certificate,
[property: JsonPropertyName("digest")] string? Digest);
public sealed record AdvisoryContentRequest(
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("specVersion")] string? SpecVersion,
[property: JsonPropertyName("raw")] JsonElement Raw,
[property: JsonPropertyName("encoding")] string? Encoding);
public sealed record AdvisoryIdentifiersRequest(
[property: JsonPropertyName("primary")] string Primary,
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases);
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record AdvisoryIngestRequest(
AdvisorySourceRequest Source,
AdvisoryUpstreamRequest Upstream,
AdvisoryContentRequest Content,
AdvisoryIdentifiersRequest Identifiers,
AdvisoryLinksetRequest? Linkset);
public sealed record AdvisorySourceRequest(
[property: JsonPropertyName("vendor")] string Vendor,
[property: JsonPropertyName("connector")] string Connector,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("stream")] string? Stream);
public sealed record AdvisoryUpstreamRequest(
[property: JsonPropertyName("upstreamId")] string UpstreamId,
[property: JsonPropertyName("documentVersion")] string? DocumentVersion,
[property: JsonPropertyName("retrievedAt")] DateTimeOffset? RetrievedAt,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("signature")] AdvisorySignatureRequest Signature,
[property: JsonPropertyName("provenance")] IDictionary<string, string>? Provenance);
public sealed record AdvisorySignatureRequest(
[property: JsonPropertyName("present")] bool Present,
[property: JsonPropertyName("format")] string? Format,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("sig")] string? Signature,
[property: JsonPropertyName("certificate")] string? Certificate,
[property: JsonPropertyName("digest")] string? Digest);
public sealed record AdvisoryContentRequest(
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("specVersion")] string? SpecVersion,
[property: JsonPropertyName("raw")] JsonElement Raw,
[property: JsonPropertyName("encoding")] string? Encoding);
public sealed record AdvisoryIdentifiersRequest(
[property: JsonPropertyName("primary")] string Primary,
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases);
public sealed record AdvisoryLinksetRequest(
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
[property: JsonPropertyName("scopes")] IReadOnlyList<string>? Scopes,
@@ -66,23 +66,23 @@ public sealed record AdvisoryLinksetReferenceRequest(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("source")] string? Source);
public sealed record AdvisoryIngestResponse(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("inserted")] bool Inserted,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("supersedes")] string? Supersedes,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
public sealed record AdvisoryRawRecordResponse(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("document")] AdvisoryRawDocument Document);
public sealed record AdvisoryIngestResponse(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("inserted")] bool Inserted,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("supersedes")] string? Supersedes,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
public sealed record AdvisoryRawRecordResponse(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("document")] AdvisoryRawDocument Document);
public sealed record AdvisoryRawListResponse(
[property: JsonPropertyName("records")] IReadOnlyList<AdvisoryRawRecordResponse> Records,
[property: JsonPropertyName("nextCursor")] string? NextCursor,
@@ -98,44 +98,44 @@ public sealed record AdvisoryRawProvenanceResponse(
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("source")] RawSourceMetadata Source,
[property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream,
[property: JsonPropertyName("supersedes")] string? Supersedes,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
public sealed record AocVerifyRequest(
[property: JsonPropertyName("since")] DateTimeOffset? Since,
[property: JsonPropertyName("until")] DateTimeOffset? Until,
[property: JsonPropertyName("limit")] int? Limit,
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources,
[property: JsonPropertyName("codes")] IReadOnlyList<string>? Codes);
public sealed record AocVerifyResponse(
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("window")] AocVerifyWindow Window,
[property: JsonPropertyName("checked")] AocVerifyChecked Checked,
[property: JsonPropertyName("violations")] IReadOnlyList<AocVerifyViolation> Violations,
[property: JsonPropertyName("metrics")] AocVerifyMetrics Metrics,
[property: JsonPropertyName("truncated")] bool Truncated);
public sealed record AocVerifyWindow(
[property: JsonPropertyName("from")] DateTimeOffset From,
[property: JsonPropertyName("to")] DateTimeOffset To);
public sealed record AocVerifyChecked(
[property: JsonPropertyName("advisories")] int Advisories,
[property: JsonPropertyName("vex")] int Vex);
public sealed record AocVerifyMetrics(
[property: JsonPropertyName("ingestion_write_total")] int IngestionWriteTotal,
[property: JsonPropertyName("aoc_violation_total")] int AocViolationTotal);
public sealed record AocVerifyViolation(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("examples")] IReadOnlyList<AocVerifyViolationExample> Examples);
public sealed record AocVerifyViolationExample(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("documentId")] string DocumentId,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("path")] string Path);
[property: JsonPropertyName("supersedes")] string? Supersedes,
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
public sealed record AocVerifyRequest(
[property: JsonPropertyName("since")] DateTimeOffset? Since,
[property: JsonPropertyName("until")] DateTimeOffset? Until,
[property: JsonPropertyName("limit")] int? Limit,
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources,
[property: JsonPropertyName("codes")] IReadOnlyList<string>? Codes);
public sealed record AocVerifyResponse(
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("window")] AocVerifyWindow Window,
[property: JsonPropertyName("checked")] AocVerifyChecked Checked,
[property: JsonPropertyName("violations")] IReadOnlyList<AocVerifyViolation> Violations,
[property: JsonPropertyName("metrics")] AocVerifyMetrics Metrics,
[property: JsonPropertyName("truncated")] bool Truncated);
public sealed record AocVerifyWindow(
[property: JsonPropertyName("from")] DateTimeOffset From,
[property: JsonPropertyName("to")] DateTimeOffset To);
public sealed record AocVerifyChecked(
[property: JsonPropertyName("advisories")] int Advisories,
[property: JsonPropertyName("vex")] int Vex);
public sealed record AocVerifyMetrics(
[property: JsonPropertyName("ingestion_write_total")] int IngestionWriteTotal,
[property: JsonPropertyName("aoc_violation_total")] int AocViolationTotal);
public sealed record AocVerifyViolation(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("examples")] IReadOnlyList<AocVerifyViolationExample> Examples);
public sealed record AocVerifyViolationExample(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("documentId")] string DocumentId,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("path")] string Path);

View File

@@ -1,27 +1,27 @@
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed record StorageHealth(
string Backend,
bool Ready,
DateTimeOffset? CheckedAt,
double? LatencyMs,
string? Error);
internal sealed record TelemetryHealth(
bool Enabled,
bool Tracing,
bool Metrics,
bool Logging);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
StorageHealth Storage,
TelemetryHealth Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
StorageHealth Storage);
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed record StorageHealth(
string Backend,
bool Ready,
DateTimeOffset? CheckedAt,
double? LatencyMs,
string? Error);
internal sealed record TelemetryHealth(
bool Enabled,
bool Tracing,
bool Metrics,
bool Logging);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
StorageHealth Storage,
TelemetryHealth Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
StorageHealth Storage);

View File

@@ -1,25 +1,25 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class JobMetrics
{
internal const string MeterName = "StellaOps.Concelier.WebService.Jobs";
private static readonly Meter Meter = new(MeterName);
internal static readonly Counter<long> TriggerCounter = Meter.CreateCounter<long>(
"web.jobs.triggered",
unit: "count",
description: "Number of job trigger requests accepted by the web service.");
internal static readonly Counter<long> TriggerConflictCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.conflict",
unit: "count",
description: "Number of job trigger requests that resulted in conflicts or rejections.");
internal static readonly Counter<long> TriggerFailureCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.failed",
unit: "count",
description: "Number of job trigger requests that failed at runtime.");
}
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class JobMetrics
{
internal const string MeterName = "StellaOps.Concelier.WebService.Jobs";
private static readonly Meter Meter = new(MeterName);
internal static readonly Counter<long> TriggerCounter = Meter.CreateCounter<long>(
"web.jobs.triggered",
unit: "count",
description: "Number of job trigger requests accepted by the web service.");
internal static readonly Counter<long> TriggerConflictCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.conflict",
unit: "count",
description: "Number of job trigger requests that resulted in conflicts or rejections.");
internal static readonly Counter<long> TriggerFailureCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.failed",
unit: "count",
description: "Number of job trigger requests that failed at runtime.");
}

View File

@@ -1,12 +1,12 @@
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class ProblemTypes
{
public const string NotFound = "https://stellaops.org/problems/not-found";
public const string Validation = "https://stellaops.org/problems/validation";
public const string Conflict = "https://stellaops.org/problems/conflict";
public const string Locked = "https://stellaops.org/problems/locked";
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";
public const string JobFailure = "https://stellaops.org/problems/job-failure";
public const string ServiceUnavailable = "https://stellaops.org/problems/service-unavailable";
}
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class ProblemTypes
{
public const string NotFound = "https://stellaops.org/problems/not-found";
public const string Validation = "https://stellaops.org/problems/validation";
public const string Conflict = "https://stellaops.org/problems/conflict";
public const string Locked = "https://stellaops.org/problems/locked";
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";
public const string JobFailure = "https://stellaops.org/problems/job-failure";
public const string ServiceUnavailable = "https://stellaops.org/problems/service-unavailable";
}

View File

@@ -1,74 +1,74 @@
using System.Diagnostics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private readonly object _sync = new();
private DateTimeOffset? _bootstrapCompletedAt;
private TimeSpan? _bootstrapDuration;
private DateTimeOffset? _lastReadyCheckAt;
private TimeSpan? _lastStorageLatency;
private string? _lastStorageError;
private bool _lastReadySucceeded;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_startedAt = _timeProvider.GetUtcNow();
}
public ServiceHealthSnapshot CreateSnapshot()
{
lock (_sync)
{
return new ServiceHealthSnapshot(
CapturedAt: _timeProvider.GetUtcNow(),
StartedAt: _startedAt,
BootstrapCompletedAt: _bootstrapCompletedAt,
BootstrapDuration: _bootstrapDuration,
LastReadyCheckAt: _lastReadyCheckAt,
LastStorageLatency: _lastStorageLatency,
LastStorageError: _lastStorageError,
LastReadySucceeded: _lastReadySucceeded);
}
}
public void MarkBootstrapCompleted(TimeSpan duration)
{
lock (_sync)
{
var completedAt = _timeProvider.GetUtcNow();
_bootstrapCompletedAt = completedAt;
_bootstrapDuration = duration;
_lastReadySucceeded = true;
_lastStorageLatency = duration;
_lastStorageError = null;
_lastReadyCheckAt = completedAt;
}
}
public void RecordStorageCheck(bool success, TimeSpan latency, string? error)
{
lock (_sync)
{
_lastReadySucceeded = success;
_lastStorageLatency = latency;
_lastStorageError = success ? null : error;
_lastReadyCheckAt = _timeProvider.GetUtcNow();
}
}
}
internal sealed record ServiceHealthSnapshot(
DateTimeOffset CapturedAt,
DateTimeOffset StartedAt,
DateTimeOffset? BootstrapCompletedAt,
TimeSpan? BootstrapDuration,
DateTimeOffset? LastReadyCheckAt,
TimeSpan? LastStorageLatency,
string? LastStorageError,
bool LastReadySucceeded);
using System.Diagnostics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private readonly object _sync = new();
private DateTimeOffset? _bootstrapCompletedAt;
private TimeSpan? _bootstrapDuration;
private DateTimeOffset? _lastReadyCheckAt;
private TimeSpan? _lastStorageLatency;
private string? _lastStorageError;
private bool _lastReadySucceeded;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_startedAt = _timeProvider.GetUtcNow();
}
public ServiceHealthSnapshot CreateSnapshot()
{
lock (_sync)
{
return new ServiceHealthSnapshot(
CapturedAt: _timeProvider.GetUtcNow(),
StartedAt: _startedAt,
BootstrapCompletedAt: _bootstrapCompletedAt,
BootstrapDuration: _bootstrapDuration,
LastReadyCheckAt: _lastReadyCheckAt,
LastStorageLatency: _lastStorageLatency,
LastStorageError: _lastStorageError,
LastReadySucceeded: _lastReadySucceeded);
}
}
public void MarkBootstrapCompleted(TimeSpan duration)
{
lock (_sync)
{
var completedAt = _timeProvider.GetUtcNow();
_bootstrapCompletedAt = completedAt;
_bootstrapDuration = duration;
_lastReadySucceeded = true;
_lastStorageLatency = duration;
_lastStorageError = null;
_lastReadyCheckAt = completedAt;
}
}
public void RecordStorageCheck(bool success, TimeSpan latency, string? error)
{
lock (_sync)
{
_lastReadySucceeded = success;
_lastStorageLatency = latency;
_lastStorageError = success ? null : error;
_lastReadyCheckAt = _timeProvider.GetUtcNow();
}
}
}
internal sealed record ServiceHealthSnapshot(
DateTimeOffset CapturedAt,
DateTimeOffset StartedAt,
DateTimeOffset? BootstrapCompletedAt,
TimeSpan? BootstrapDuration,
DateTimeOffset? LastReadyCheckAt,
TimeSpan? LastStorageLatency,
string? LastStorageError,
bool LastReadySucceeded);

View File

@@ -5,16 +5,16 @@ using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.WebService.Contracts;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class AdvisoryRawRequestMapper
{
internal static AdvisoryRawDocument Map(AdvisoryIngestRequest request, string tenant, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(timeProvider);
var sourceRequest = request.Source ?? throw new ArgumentException("source section is required.", nameof(request));
internal static class AdvisoryRawRequestMapper
{
internal static AdvisoryRawDocument Map(AdvisoryIngestRequest request, string tenant, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(timeProvider);
var sourceRequest = request.Source ?? throw new ArgumentException("source section is required.", nameof(request));
var upstreamRequest = request.Upstream ?? throw new ArgumentException("upstream section is required.", nameof(request));
var contentRequest = request.Content ?? throw new ArgumentException("content section is required.", nameof(request));
var identifiersRequest = request.Identifiers ?? throw new ArgumentException("identifiers section is required.", nameof(request));
@@ -22,18 +22,18 @@ internal static class AdvisoryRawRequestMapper
var source = new RawSourceMetadata(
sourceRequest.Vendor,
sourceRequest.Connector,
sourceRequest.Version,
string.IsNullOrWhiteSpace(sourceRequest.Stream) ? null : sourceRequest.Stream);
var signatureRequest = upstreamRequest.Signature ?? new AdvisorySignatureRequest(false, null, null, null, null, null);
var signature = new RawSignatureMetadata(
signatureRequest.Present,
string.IsNullOrWhiteSpace(signatureRequest.Format) ? null : signatureRequest.Format,
string.IsNullOrWhiteSpace(signatureRequest.KeyId) ? null : signatureRequest.KeyId,
string.IsNullOrWhiteSpace(signatureRequest.Signature) ? null : signatureRequest.Signature,
string.IsNullOrWhiteSpace(signatureRequest.Certificate) ? null : signatureRequest.Certificate,
string.IsNullOrWhiteSpace(signatureRequest.Digest) ? null : signatureRequest.Digest);
sourceRequest.Version,
string.IsNullOrWhiteSpace(sourceRequest.Stream) ? null : sourceRequest.Stream);
var signatureRequest = upstreamRequest.Signature ?? new AdvisorySignatureRequest(false, null, null, null, null, null);
var signature = new RawSignatureMetadata(
signatureRequest.Present,
string.IsNullOrWhiteSpace(signatureRequest.Format) ? null : signatureRequest.Format,
string.IsNullOrWhiteSpace(signatureRequest.KeyId) ? null : signatureRequest.KeyId,
string.IsNullOrWhiteSpace(signatureRequest.Signature) ? null : signatureRequest.Signature,
string.IsNullOrWhiteSpace(signatureRequest.Certificate) ? null : signatureRequest.Certificate,
string.IsNullOrWhiteSpace(signatureRequest.Digest) ? null : signatureRequest.Digest);
var retrievedAt = upstreamRequest.RetrievedAt ?? timeProvider.GetUtcNow();
var upstream = new RawUpstreamMetadata(
upstreamRequest.UpstreamId,
@@ -49,13 +49,13 @@ internal static class AdvisoryRawRequestMapper
string.IsNullOrWhiteSpace(contentRequest.SpecVersion) ? null : contentRequest.SpecVersion,
rawContent,
string.IsNullOrWhiteSpace(contentRequest.Encoding) ? null : contentRequest.Encoding);
var aliases = NormalizeStrings(identifiersRequest.Aliases);
if (aliases.IsDefault)
{
aliases = ImmutableArray<string>.Empty;
}
var aliases = NormalizeStrings(identifiersRequest.Aliases);
if (aliases.IsDefault)
{
aliases = ImmutableArray<string>.Empty;
}
var identifiers = new RawIdentifiers(
aliases,
identifiersRequest.Primary);
@@ -89,70 +89,70 @@ internal static class AdvisoryRawRequestMapper
AdvisoryKey: advisoryKey,
Links: links);
}
internal static ImmutableArray<string> NormalizeStrings(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Add(value.Trim());
}
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
}
internal static ImmutableDictionary<string, string> NormalizeDictionary(IDictionary<string, string>? values)
{
if (values is null || values.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var kv in values)
{
if (string.IsNullOrWhiteSpace(kv.Key))
{
continue;
}
builder[kv.Key.Trim()] = kv.Value?.Trim() ?? string.Empty;
}
return builder.ToImmutable();
}
internal static ImmutableArray<string> NormalizeStrings(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Add(value.Trim());
}
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
}
internal static ImmutableDictionary<string, string> NormalizeDictionary(IDictionary<string, string>? values)
{
if (values is null || values.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var kv in values)
{
if (string.IsNullOrWhiteSpace(kv.Key))
{
continue;
}
builder[kv.Key.Trim()] = kv.Value?.Trim() ?? string.Empty;
}
return builder.ToImmutable();
}
private static ImmutableArray<RawReference> NormalizeReferences(IEnumerable<AdvisoryLinksetReferenceRequest>? references)
{
if (references is null)
{
return ImmutableArray<RawReference>.Empty;
}
var builder = ImmutableArray.CreateBuilder<RawReference>();
foreach (var reference in references)
{
if (reference is null)
{
continue;
}
if (string.IsNullOrWhiteSpace(reference.Type) || string.IsNullOrWhiteSpace(reference.Url))
{
continue;
}
builder.Add(new RawReference(reference.Type.Trim(), reference.Url.Trim(), string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
var builder = ImmutableArray.CreateBuilder<RawReference>();
foreach (var reference in references)
{
if (reference is null)
{
continue;
}
if (string.IsNullOrWhiteSpace(reference.Type) || string.IsNullOrWhiteSpace(reference.Url))
{
continue;
}
builder.Add(new RawReference(reference.Type.Trim(), reference.Url.Trim(), string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
}
return builder.Count == 0 ? ImmutableArray<RawReference>.Empty : builder.ToImmutable();
@@ -185,7 +185,7 @@ internal static class AdvisoryRawRequestMapper
return builder.Count == 0 ? ImmutableArray<RawRelationship>.Empty : builder.ToImmutable();
}
private static JsonElement NormalizeRawContent(JsonElement element)
{
var json = element.ValueKind == JsonValueKind.Undefined ? "{}" : element.GetRawText();

View File

@@ -1,38 +1,38 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Concelier.WebService.Extensions;
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddConcelierYaml(this IConfigurationBuilder builder, string path)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var json = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
return builder.AddJsonStream(stream);
}
}
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Concelier.WebService.Extensions;
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddConcelierYaml(this IConfigurationBuilder builder, string path)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var json = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
return builder.AddJsonStream(stream);
}
}

View File

@@ -1,208 +1,208 @@
using System.Globalization;
using System.IO;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Diagnostics;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class MirrorEndpointExtensions
{
private const string IndexScope = "index";
private const string DownloadScope = "download";
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
{
app.MapGet("/concelier/exports/index.json", async (
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
}
if (!locator.TryResolveIndex(out var path, out _))
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
});
app.MapGet("/concelier/exports/{**relativePath}", async (
string? relativePath,
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (string.IsNullOrWhiteSpace(relativePath))
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
{
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
}
var domain = FindDomain(mirrorOptions, domainId);
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
}
var contentType = ResolveContentType(path);
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
});
}
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
{
if (domainId is null)
{
return null;
}
foreach (var candidate in mirrorOptions.Domains)
{
if (candidate is null)
{
continue;
}
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
{
result = HttpResults.Empty;
if (!requireAuthentication)
{
return true;
}
if (!enforceAuthority || !authorityConfigured)
{
return true;
}
if (context.User?.Identity?.IsAuthenticated == true)
{
return true;
}
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
result = HttpResults.StatusCode(StatusCodes.Status401Unauthorized);
return false;
}
private static Task<IResult> WriteFileAsync(HttpContext context, string path, string contentType)
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists)
{
return Task.FromResult(ConcelierProblemResultFactory.MirrorNotFound(context, path));
}
var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read | FileShare.Delete);
context.Response.Headers.CacheControl = BuildCacheControlHeader(path);
context.Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
context.Response.ContentLength = fileInfo.Length;
return Task.FromResult(HttpResults.Stream(stream, contentType));
}
private static string ResolveContentType(string path)
{
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "application/jose+json";
}
return "application/octet-stream";
}
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
{
if (retryAfter is null)
{
return;
}
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
}
private static string BuildCacheControlHeader(string path)
{
var fileName = Path.GetFileName(path);
if (fileName is null)
{
return "public, max-age=60";
}
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
{
return "public, max-age=60";
}
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "public, max-age=300, immutable";
}
return "public, max-age=300";
}
}
using System.Globalization;
using System.IO;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Diagnostics;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class MirrorEndpointExtensions
{
private const string IndexScope = "index";
private const string DownloadScope = "download";
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
{
app.MapGet("/concelier/exports/index.json", async (
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
}
if (!locator.TryResolveIndex(out var path, out _))
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
});
app.MapGet("/concelier/exports/{**relativePath}", async (
string? relativePath,
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (string.IsNullOrWhiteSpace(relativePath))
{
return ConcelierProblemResultFactory.MirrorNotFound(context);
}
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
{
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
}
var domain = FindDomain(mirrorOptions, domainId);
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
}
var contentType = ResolveContentType(path);
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
});
}
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
{
if (domainId is null)
{
return null;
}
foreach (var candidate in mirrorOptions.Domains)
{
if (candidate is null)
{
continue;
}
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
{
result = HttpResults.Empty;
if (!requireAuthentication)
{
return true;
}
if (!enforceAuthority || !authorityConfigured)
{
return true;
}
if (context.User?.Identity?.IsAuthenticated == true)
{
return true;
}
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
result = HttpResults.StatusCode(StatusCodes.Status401Unauthorized);
return false;
}
private static Task<IResult> WriteFileAsync(HttpContext context, string path, string contentType)
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists)
{
return Task.FromResult(ConcelierProblemResultFactory.MirrorNotFound(context, path));
}
var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read | FileShare.Delete);
context.Response.Headers.CacheControl = BuildCacheControlHeader(path);
context.Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
context.Response.ContentLength = fileInfo.Length;
return Task.FromResult(HttpResults.Stream(stream, contentType));
}
private static string ResolveContentType(string path)
{
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "application/jose+json";
}
return "application/octet-stream";
}
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
{
if (retryAfter is null)
{
return;
}
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
}
private static string BuildCacheControlHeader(string path)
{
var fileName = Path.GetFileName(path);
if (fileName is null)
{
return "public, max-age=60";
}
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
{
return "public, max-age=60";
}
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "public, max-age=300, immutable";
}
return "public, max-age=300";
}
}

View File

@@ -1,104 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Filters;
/// <summary>
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
/// </summary>
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
{
internal const string LoggerName = "Concelier.Authorization.Audit";
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
var httpContext = context.HttpContext;
var options = httpContext.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value;
var authority = options.Authority;
if (authority is null || !authority.Enabled)
{
return await next(context).ConfigureAwait(false);
}
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(LoggerName);
var remoteAddress = httpContext.Connection.RemoteIpAddress;
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
var user = httpContext.User;
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
var result = await next(context).ConfigureAwait(false);
var scopes = ExtractScopes(user);
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
logger.LogInformation(
"Concelier authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
httpContext.Request.Path.Value ?? string.Empty,
httpContext.Response.StatusCode,
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
bypassUsed,
remoteAddress?.ToString() ?? IPAddress.None.ToString());
return result;
}
private static string[] ExtractScopes(ClaimsPrincipal? principal)
{
if (principal is null)
{
return Array.Empty<string>();
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
values.Add(claim.Value);
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (!string.IsNullOrEmpty(normalized))
{
values.Add(normalized);
}
}
}
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Filters;
/// <summary>
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
/// </summary>
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
{
internal const string LoggerName = "Concelier.Authorization.Audit";
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
var httpContext = context.HttpContext;
var options = httpContext.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value;
var authority = options.Authority;
if (authority is null || !authority.Enabled)
{
return await next(context).ConfigureAwait(false);
}
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(LoggerName);
var remoteAddress = httpContext.Connection.RemoteIpAddress;
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
var user = httpContext.User;
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
var result = await next(context).ConfigureAwait(false);
var scopes = ExtractScopes(user);
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
logger.LogInformation(
"Concelier authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
httpContext.Request.Path.Value ?? string.Empty,
httpContext.Response.StatusCode,
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
bypassUsed,
remoteAddress?.ToString() ?? IPAddress.None.ToString());
return result;
}
private static string[] ExtractScopes(ClaimsPrincipal? principal)
{
if (principal is null)
{
return Array.Empty<string>();
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
values.Add(claim.Value);
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (!string.IsNullOrEmpty(normalized))
{
values.Add(normalized);
}
}
}
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
}
}

View File

@@ -1,23 +1,23 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobDefinitionResponse(
string Kind,
bool Enabled,
string? CronExpression,
TimeSpan Timeout,
TimeSpan LeaseDuration,
JobRunResponse? LastRun)
{
public static JobDefinitionResponse FromDefinition(JobDefinition definition, JobRunSnapshot? lastRun)
{
return new JobDefinitionResponse(
definition.Kind,
definition.Enabled,
definition.CronExpression,
definition.Timeout,
definition.LeaseDuration,
lastRun is null ? null : JobRunResponse.FromSnapshot(lastRun));
}
}
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobDefinitionResponse(
string Kind,
bool Enabled,
string? CronExpression,
TimeSpan Timeout,
TimeSpan LeaseDuration,
JobRunResponse? LastRun)
{
public static JobDefinitionResponse FromDefinition(JobDefinition definition, JobRunSnapshot? lastRun)
{
return new JobDefinitionResponse(
definition.Kind,
definition.Enabled,
definition.CronExpression,
definition.Timeout,
definition.LeaseDuration,
lastRun is null ? null : JobRunResponse.FromSnapshot(lastRun));
}
}

View File

@@ -1,29 +1,29 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobRunResponse(
Guid RunId,
string Kind,
JobRunStatus Status,
string Trigger,
DateTimeOffset CreatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
string? Error,
TimeSpan? Duration,
IReadOnlyDictionary<string, object?> Parameters)
{
public static JobRunResponse FromSnapshot(JobRunSnapshot snapshot)
=> new(
snapshot.RunId,
snapshot.Kind,
snapshot.Status,
snapshot.Trigger,
snapshot.CreatedAt,
snapshot.StartedAt,
snapshot.CompletedAt,
snapshot.Error,
snapshot.Duration,
snapshot.Parameters);
}
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobRunResponse(
Guid RunId,
string Kind,
JobRunStatus Status,
string Trigger,
DateTimeOffset CreatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
string? Error,
TimeSpan? Duration,
IReadOnlyDictionary<string, object?> Parameters)
{
public static JobRunResponse FromSnapshot(JobRunSnapshot snapshot)
=> new(
snapshot.RunId,
snapshot.Kind,
snapshot.Status,
snapshot.Trigger,
snapshot.CreatedAt,
snapshot.StartedAt,
snapshot.CompletedAt,
snapshot.Error,
snapshot.Duration,
snapshot.Parameters);
}

View File

@@ -1,8 +1,8 @@
namespace StellaOps.Concelier.WebService.Jobs;
public sealed class JobTriggerRequest
{
public string Trigger { get; set; } = "api";
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
}
namespace StellaOps.Concelier.WebService.Jobs;
public sealed class JobTriggerRequest
{
public string Trigger { get; set; } = "api";
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -1,71 +1,71 @@
using System;
using System.IO;
namespace StellaOps.Concelier.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ConcelierOptions"/>.
/// </summary>
public static class ConcelierOptionsPostConfigure
{
/// <summary>
/// Applies derived settings that require filesystem access, such as loading client secrets from disk.
/// </summary>
/// <param name="options">The options to mutate.</param>
/// <param name="contentRootPath">Application content root used to resolve relative paths.</param>
public static void Apply(ConcelierOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
using System;
using System.IO;
namespace StellaOps.Concelier.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ConcelierOptions"/>.
/// </summary>
public static class ConcelierOptionsPostConfigure
{
/// <summary>
/// Applies derived settings that require filesystem access, such as loading client secrets from disk.
/// </summary>
/// <param name="options">The options to mutate.</param>
/// <param name="contentRootPath">Application content root used to resolve relative paths.</param>
public static void Apply(ConcelierOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
options.Authority ??= new ConcelierOptions.AuthorityOptions();
options.Features ??= new ConcelierOptions.FeaturesOptions();
options.Evidence ??= new ConcelierOptions.EvidenceBundleOptions();
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
var resolvedPath = authority.ClientSecretFile!;
if (!Path.IsPathRooted(resolvedPath))
{
resolvedPath = Path.Combine(contentRootPath, resolvedPath);
}
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty.");
}
authority.ClientSecret = secret;
}
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
var resolvedPath = authority.ClientSecretFile!;
if (!Path.IsPathRooted(resolvedPath))
{
resolvedPath = Path.Combine(contentRootPath, resolvedPath);
}
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty.");
}
authority.ClientSecret = secret;
}
options.Mirror ??= new ConcelierOptions.MirrorOptions();
var mirror = options.Mirror;
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
mirror.ExportRoot = Path.Combine("exports", "json");
}
var resolvedRoot = mirror.ExportRoot;
if (!Path.IsPathRooted(resolvedRoot))
{
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
}
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
mirror.LatestDirectoryName = "latest";
}
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
mirror.ExportRoot = Path.Combine("exports", "json");
}
var resolvedRoot = mirror.ExportRoot;
if (!Path.IsPathRooted(resolvedRoot))
{
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
}
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
mirror.LatestDirectoryName = "latest";
}
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
{
mirror.MirrorDirectoryName = "mirror";

View File

@@ -65,7 +65,7 @@ using StellaOps.Aoc.AspNetCore.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Aliases;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
namespace StellaOps.Concelier.WebService
{

View File

@@ -1,184 +1,184 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorFileLocator
{
private readonly IOptionsMonitor<ConcelierOptions> _options;
private readonly ILogger<MirrorFileLocator> _logger;
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
{
fullPath = null;
exportId = null;
domainId = null;
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirror.Enabled)
{
return false;
}
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
{
return false;
}
var sanitized = SanitizeRelativePath(relativePath);
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
{
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
}
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var candidate = Combine(exportDirectory, sanitized);
if (!CandidateWithinExport(exportDirectory, candidate))
{
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
return false;
}
if (!File.Exists(candidate))
{
return false;
}
// Extract domain id from path mirror/<domain>/...
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2)
{
domainId = segments[1];
}
fullPath = candidate;
return true;
}
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
return false;
}
var root = mirror.ExportRootAbsolute;
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
? mirror.LatestDirectoryName
: mirror.ActiveExportId!;
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
try
{
var directories = Directory.Exists(root)
? Directory.GetDirectories(root)
: Array.Empty<string>();
Array.Sort(directories, StringComparer.Ordinal);
Array.Reverse(directories);
foreach (var directory in directories)
{
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
}
return false;
}
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(segment))
{
return false;
}
var candidate = Path.Combine(root, segment);
if (!Directory.Exists(candidate))
{
return false;
}
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
if (!Directory.Exists(mirrorPath))
{
return false;
}
exportDirectory = candidate;
exportId = segment;
return true;
}
private static string SanitizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
return trimmed;
}
private static string Combine(string root, string relativePath)
{
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return Path.GetFullPath(root);
}
var combinedRelative = Path.Combine(segments);
return Path.GetFullPath(Path.Combine(root, combinedRelative));
}
private static bool CandidateWithinExport(string exportDirectory, string candidate)
{
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var candidatePath = Path.GetFullPath(candidate);
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorFileLocator
{
private readonly IOptionsMonitor<ConcelierOptions> _options;
private readonly ILogger<MirrorFileLocator> _logger;
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
{
fullPath = null;
exportId = null;
domainId = null;
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirror.Enabled)
{
return false;
}
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
{
return false;
}
var sanitized = SanitizeRelativePath(relativePath);
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
{
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
}
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var candidate = Combine(exportDirectory, sanitized);
if (!CandidateWithinExport(exportDirectory, candidate))
{
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
return false;
}
if (!File.Exists(candidate))
{
return false;
}
// Extract domain id from path mirror/<domain>/...
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2)
{
domainId = segments[1];
}
fullPath = candidate;
return true;
}
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
return false;
}
var root = mirror.ExportRootAbsolute;
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
? mirror.LatestDirectoryName
: mirror.ActiveExportId!;
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
try
{
var directories = Directory.Exists(root)
? Directory.GetDirectories(root)
: Array.Empty<string>();
Array.Sort(directories, StringComparer.Ordinal);
Array.Reverse(directories);
foreach (var directory in directories)
{
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
}
return false;
}
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(segment))
{
return false;
}
var candidate = Path.Combine(root, segment);
if (!Directory.Exists(candidate))
{
return false;
}
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
if (!Directory.Exists(mirrorPath))
{
return false;
}
exportDirectory = candidate;
exportId = segment;
return true;
}
private static string SanitizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
return trimmed;
}
private static string Combine(string root, string relativePath)
{
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return Path.GetFullPath(root);
}
var combinedRelative = Path.Combine(segments);
return Path.GetFullPath(Path.Combine(root, combinedRelative));
}
private static bool CandidateWithinExport(string exportDirectory, string candidate)
{
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var candidatePath = Path.GetFullPath(candidate);
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,57 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorRateLimiter
{
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
{
retryAfter = null;
if (limit <= 0 || limit == int.MaxValue)
{
return true;
}
var key = CreateKey(domainId, scope);
var now = _timeProvider.GetUtcNow();
var counter = _cache.Get<Counter>(key);
if (counter is null || now - counter.WindowStart >= Window)
{
counter = new Counter(now, 0);
}
if (counter.Count >= limit)
{
var windowEnd = counter.WindowStart + Window;
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return false;
}
counter = counter with { Count = counter.Count + 1 };
var absoluteExpiration = counter.WindowStart + Window;
_cache.Set(key, counter, absoluteExpiration);
return true;
}
private static string CreateKey(string domainId, string scope)
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
{
state.domainId.AsSpan().CopyTo(span);
span[state.domainId.Length] = '|';
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
});
private sealed record Counter(DateTimeOffset WindowStart, int Count);
}
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorRateLimiter
{
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
{
retryAfter = null;
if (limit <= 0 || limit == int.MaxValue)
{
return true;
}
var key = CreateKey(domainId, scope);
var now = _timeProvider.GetUtcNow();
var counter = _cache.Get<Counter>(key);
if (counter is null || now - counter.WindowStart >= Window)
{
counter = new Counter(now, 0);
}
if (counter.Count >= limit)
{
var windowEnd = counter.WindowStart + Window;
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return false;
}
counter = counter with { Count = counter.Count + 1 };
var absoluteExpiration = counter.WindowStart + Window;
_cache.Set(key, counter, absoluteExpiration);
return true;
}
private static string CreateKey(string domainId, string scope)
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
{
state.domainId.AsSpan().CopyTo(span);
span[state.domainId.Length] = '|';
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
});
private sealed record Counter(DateTimeOffset WindowStart, int Count);
}