feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
2025-10-19 18:36:22 +03:00
parent 7e2fa0a42a
commit 5ce40d2eeb
966 changed files with 91038 additions and 1850 deletions

View File

@@ -9,6 +9,11 @@
<IsAuthorityPlugin Condition="'$(IsAuthorityPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Authority.Plugin.'))">true</IsAuthorityPlugin>
<ScannerBuildxPluginOutputRoot Condition="'$(ScannerBuildxPluginOutputRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\plugins\scanner\buildx\'))</ScannerBuildxPluginOutputRoot>
<IsScannerBuildxPlugin Condition="'$(IsScannerBuildxPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)')) == 'StellaOps.Scanner.Sbomer.BuildXPlugin'">true</IsScannerBuildxPlugin>
<ScannerOsAnalyzerPluginOutputRoot Condition="'$(ScannerOsAnalyzerPluginOutputRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\plugins\scanner\analyzers\os\'))</ScannerOsAnalyzerPluginOutputRoot>
<IsScannerOsAnalyzerPlugin Condition="'$(IsScannerOsAnalyzerPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Scanner.Analyzers.OS.')) and !$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">true</IsScannerOsAnalyzerPlugin>
<ScannerLangAnalyzerPluginOutputRoot Condition="'$(ScannerLangAnalyzerPluginOutputRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\plugins\scanner\analyzers\lang\'))</ScannerLangAnalyzerPluginOutputRoot>
<IsScannerLangAnalyzerPlugin Condition="'$(IsScannerLangAnalyzerPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Scanner.Analyzers.Lang.'))">true</IsScannerLangAnalyzerPlugin>
<UseConcelierTestInfra Condition="'$(UseConcelierTestInfra)' == ''">true</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
@@ -18,11 +23,11 @@
</ProjectReference>
</ItemGroup>
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.4.0" />

View File

@@ -47,4 +47,38 @@
<Copy SourceFiles="@(ScannerBuildxPluginArtifacts)" DestinationFolder="$(ScannerBuildxPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
<Target Name="ScannerCopyOsAnalyzerPluginArtifacts" AfterTargets="Build" Condition="'$(IsScannerOsAnalyzerPlugin)' == 'true'">
<PropertyGroup>
<ScannerOsAnalyzerPluginOutputDirectory>$(ScannerOsAnalyzerPluginOutputRoot)\$(MSBuildProjectName)</ScannerOsAnalyzerPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(ScannerOsAnalyzerPluginOutputDirectory)" />
<ItemGroup>
<ScannerOsAnalyzerPluginArtifacts Include="$(TargetPath)" />
<ScannerOsAnalyzerPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<ScannerOsAnalyzerPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<ScannerOsAnalyzerPluginArtifacts Include="$(ProjectDir)manifest.json" Condition="Exists('$(ProjectDir)manifest.json')" />
</ItemGroup>
<Copy SourceFiles="@(ScannerOsAnalyzerPluginArtifacts)" DestinationFolder="$(ScannerOsAnalyzerPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
<Target Name="ScannerCopyLangAnalyzerPluginArtifacts" AfterTargets="Build" Condition="'$(IsScannerLangAnalyzerPlugin)' == 'true'">
<PropertyGroup>
<ScannerLangAnalyzerPluginOutputDirectory>$(ScannerLangAnalyzerPluginOutputRoot)\$(MSBuildProjectName)</ScannerLangAnalyzerPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(ScannerLangAnalyzerPluginOutputDirectory)" />
<ItemGroup>
<ScannerLangAnalyzerPluginArtifacts Include="$(TargetPath)" />
<ScannerLangAnalyzerPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<ScannerLangAnalyzerPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<ScannerLangAnalyzerPluginArtifacts Include="$(ProjectDir)manifest.json" Condition="Exists('$(ProjectDir)manifest.json')" />
</ItemGroup>
<Copy SourceFiles="@(ScannerLangAnalyzerPluginArtifacts)" DestinationFolder="$(ScannerLangAnalyzerPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,21 @@
# Attestor Guild
## Mission
Operate the StellaOps Attestor service: accept signed DSSE envelopes from the Signer over mTLS, submit them to Rekor v2, persist inclusion proofs, and expose verification APIs for downstream services and operators.
## Teams On Call
- Team 11 (Attestor API)
- Team 12 (Attestor Observability) — partners on logging, metrics, and alerting
## Operating Principles
- Enforce mTLS + Authority tokens for every submission; never accept anonymous callers.
- Deterministic hashing, canonical JSON, and idempotent Rekor interactions (`bundleSha256` is the source of truth).
- Persist everything (entries, dedupe, audit) before acknowledging; background jobs must be resumable.
- Structured logs + metrics for each stage (`validate`, `submit`, `proof`, `persist`, `archive`).
- Update `TASKS.md`, architecture docs, and tests whenever behaviour changes.
## Key Directories
- `src/StellaOps.Attestor/StellaOps.Attestor.WebService/` — Minimal API host and HTTP surface.
- `src/StellaOps.Attestor/StellaOps.Attestor.Core/` — Domain contracts, submission/verification pipelines.
- `src/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/` — Mongo, Redis, Rekor, and archival implementations.
- `src/StellaOps.Attestor/StellaOps.Attestor.Tests/` — Unit and integration tests.

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Audit;
public sealed class AttestorAuditRecord
{
public string Action { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string? RekorUuid { get; init; }
public long? Index { get; init; }
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public string Backend { get; init; } = string.Empty;
public long LatencyMs { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public CallerDescriptor Caller { get; init; } = new();
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
public sealed class CallerDescriptor
{
public string? Subject { get; init; }
public string? Audience { get; init; }
public string? ClientId { get; init; }
public string? MtlsThumbprint { get; init; }
public string? Tenant { get; init; }
}
}

View File

@@ -0,0 +1,45 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Attestor.Core.Observability;
public sealed class AttestorMetrics : IDisposable
{
public const string MeterName = "StellaOps.Attestor";
private readonly Meter _meter;
private bool _disposed;
public AttestorMetrics()
{
_meter = new Meter(MeterName);
SubmitTotal = _meter.CreateCounter<long>("attestor.submit_total", description: "Total submission attempts grouped by result and backend.");
SubmitLatency = _meter.CreateHistogram<double>("attestor.submit_latency_seconds", unit: "s", description: "Submission latency in seconds per backend.");
ProofFetchTotal = _meter.CreateCounter<long>("attestor.proof_fetch_total", description: "Proof fetch attempts grouped by result.");
VerifyTotal = _meter.CreateCounter<long>("attestor.verify_total", description: "Verification attempts grouped by result.");
DedupeHitsTotal = _meter.CreateCounter<long>("attestor.dedupe_hits_total", description: "Number of dedupe hits by outcome.");
ErrorTotal = _meter.CreateCounter<long>("attestor.errors_total", description: "Total errors grouped by type.");
}
public Counter<long> SubmitTotal { get; }
public Histogram<double> SubmitLatency { get; }
public Counter<long> ProofFetchTotal { get; }
public Counter<long> VerifyTotal { get; }
public Counter<long> DedupeHitsTotal { get; }
public Counter<long> ErrorTotal { get; }
public void Dispose()
{
if (_disposed)
{
return;
}
_meter.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,144 @@
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Options;
/// <summary>
/// Strongly typed configuration for the Attestor service.
/// </summary>
public sealed class AttestorOptions
{
public string Listen { get; set; } = "https://0.0.0.0:8444";
public SecurityOptions Security { get; set; } = new();
public RekorOptions Rekor { get; set; } = new();
public MongoOptions Mongo { get; set; } = new();
public RedisOptions Redis { get; set; } = new();
public S3Options S3 { get; set; } = new();
public QuotaOptions Quotas { get; set; } = new();
public TelemetryOptions Telemetry { get; set; } = new();
public sealed class SecurityOptions
{
public MtlsOptions Mtls { get; set; } = new();
public AuthorityOptions Authority { get; set; } = new();
public SignerIdentityOptions SignerIdentity { get; set; } = new();
}
public sealed class MtlsOptions
{
public bool RequireClientCertificate { get; set; } = true;
public string? CaBundle { get; set; }
}
public sealed class AuthorityOptions
{
public string? Issuer { get; set; }
public string? JwksUrl { get; set; }
public string? RequireSenderConstraint { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public IList<string> Audiences { get; set; } = new List<string>();
public IList<string> RequiredScopes { get; set; } = new List<string>();
}
public sealed class SignerIdentityOptions
{
public IList<string> Mode { get; set; } = new List<string> { "keyless", "kms" };
public IList<string> FulcioRoots { get; set; } = new List<string>();
public IList<string> AllowedSans { get; set; } = new List<string>();
public IList<string> KmsKeys { get; set; } = new List<string>();
}
public sealed class RekorOptions
{
public RekorBackendOptions Primary { get; set; } = new();
public RekorMirrorOptions Mirror { get; set; } = new();
}
public class RekorBackendOptions
{
public string? Url { get; set; }
public int ProofTimeoutMs { get; set; } = 15_000;
public int PollIntervalMs { get; set; } = 250;
public int MaxAttempts { get; set; } = 60;
}
public sealed class RekorMirrorOptions : RekorBackendOptions
{
public bool Enabled { get; set; }
}
public sealed class MongoOptions
{
public string? Uri { get; set; }
public string Database { get; set; } = "attestor";
public string EntriesCollection { get; set; } = "entries";
public string DedupeCollection { get; set; } = "dedupe";
public string AuditCollection { get; set; } = "audit";
}
public sealed class RedisOptions
{
public string? Url { get; set; }
public string? DedupePrefix { get; set; } = "attestor:dedupe:";
}
public sealed class S3Options
{
public bool Enabled { get; set; }
public string? Endpoint { get; set; }
public string? Bucket { get; set; }
public string? Prefix { get; set; }
public string? ObjectLockMode { get; set; }
public bool UseTls { get; set; } = true;
}
public sealed class QuotaOptions
{
public PerCallerQuotaOptions PerCaller { get; set; } = new();
}
public sealed class PerCallerQuotaOptions
{
public int Qps { get; set; } = 50;
public int Burst { get; set; } = 100;
}
public sealed class TelemetryOptions
{
public bool EnableLogging { get; set; } = true;
public bool EnableTracing { get; set; } = false;
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Core.Rekor;
public interface IRekorClient
{
Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default);
Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorBackend
{
public required string Name { get; init; }
public required Uri Url { get; init; }
public TimeSpan ProofTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
public int MaxAttempts { get; init; } = 60;
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorProofResponse
{
[JsonPropertyName("checkpoint")]
public RekorCheckpoint? Checkpoint { get; set; }
[JsonPropertyName("inclusion")]
public RekorInclusionProof? Inclusion { get; set; }
public sealed class RekorCheckpoint
{
[JsonPropertyName("origin")]
public string? Origin { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("rootHash")]
public string? RootHash { get; set; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; set; }
}
public sealed class RekorInclusionProof
{
[JsonPropertyName("leafHash")]
public string? LeafHash { get; set; }
[JsonPropertyName("path")]
public IReadOnlyList<string> Path { get; set; } = Array.Empty<string>();
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorSubmissionResponse
{
[JsonPropertyName("uuid")]
public string Uuid { get; set; } = string.Empty;
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "included";
[JsonPropertyName("proof")]
public RekorProofResponse? Proof { get; set; }
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Storage;
public sealed class AttestorArchiveBundle
{
public string RekorUuid { get; init; } = string.Empty;
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public byte[] CanonicalBundleJson { get; init; } = Array.Empty<byte>();
public byte[] ProofJson { get; init; } = Array.Empty<byte>();
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Storage;
/// <summary>
/// Canonical representation of a Rekor entry persisted in Mongo.
/// </summary>
public sealed class AttestorEntry
{
public string RekorUuid { get; init; } = string.Empty;
public ArtifactDescriptor Artifact { get; init; } = new();
public string BundleSha256 { get; init; } = string.Empty;
public long? Index { get; init; }
public ProofDescriptor? Proof { get; init; }
public LogDescriptor Log { get; init; } = new();
public DateTimeOffset CreatedAt { get; init; }
public string Status { get; init; } = "pending";
public SignerIdentityDescriptor SignerIdentity { get; init; } = new();
public sealed class ArtifactDescriptor
{
public string Sha256 { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public string? ImageDigest { get; init; }
public string? SubjectUri { get; init; }
}
public sealed class ProofDescriptor
{
public CheckpointDescriptor? Checkpoint { get; init; }
public InclusionDescriptor? Inclusion { get; init; }
}
public sealed class CheckpointDescriptor
{
public string? Origin { get; init; }
public long Size { get; init; }
public string? RootHash { get; init; }
public DateTimeOffset? Timestamp { get; init; }
}
public sealed class InclusionDescriptor
{
public string? LeafHash { get; init; }
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
}
public sealed class LogDescriptor
{
public string Url { get; init; } = string.Empty;
public string? LogId { get; init; }
}
public sealed class SignerIdentityDescriptor
{
public string Mode { get; init; } = string.Empty;
public string? Issuer { get; init; }
public string? SubjectAlternativeName { get; init; }
public string? KeyId { get; init; }
}
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorArchiveStore
{
Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Attestor.Core.Audit;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorAuditSink
{
Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorDedupeStore
{
Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default);
Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorEntryRepository
{
Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default);
Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default);
Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Incoming submission payload for <c>/api/v1/rekor/entries</c>.
/// </summary>
public sealed class AttestorSubmissionRequest
{
[JsonPropertyName("bundle")]
public SubmissionBundle Bundle { get; set; } = new();
[JsonPropertyName("meta")]
public SubmissionMeta Meta { get; set; } = new();
public sealed class SubmissionBundle
{
[JsonPropertyName("dsse")]
public DsseEnvelope Dsse { get; set; } = new();
[JsonPropertyName("certificateChain")]
public IList<string> CertificateChain { get; set; } = new List<string>();
[JsonPropertyName("mode")]
public string Mode { get; set; } = "keyless";
}
public sealed class DsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; set; } = string.Empty;
[JsonPropertyName("payload")]
public string PayloadBase64 { get; set; } = string.Empty;
[JsonPropertyName("signatures")]
public IList<DsseSignature> Signatures { get; set; } = new List<DsseSignature>();
}
public sealed class DsseSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string Signature { get; set; } = string.Empty;
}
public sealed class SubmissionMeta
{
[JsonPropertyName("artifact")]
public ArtifactInfo Artifact { get; set; } = new();
[JsonPropertyName("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[JsonPropertyName("logPreference")]
public string LogPreference { get; set; } = "primary";
[JsonPropertyName("archive")]
public bool Archive { get; set; } = true;
}
public sealed class ArtifactInfo
{
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; set; }
[JsonPropertyName("subjectUri")]
public string? SubjectUri { get; set; }
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Result returned to callers after processing a submission.
/// </summary>
public sealed class AttestorSubmissionResult
{
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("proof")]
public RekorProof? Proof { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
public sealed class RekorProof
{
[JsonPropertyName("checkpoint")]
public Checkpoint? Checkpoint { get; set; }
[JsonPropertyName("inclusion")]
public InclusionProof? Inclusion { get; set; }
}
public sealed class Checkpoint
{
[JsonPropertyName("origin")]
public string? Origin { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("rootHash")]
public string? RootHash { get; set; }
[JsonPropertyName("timestamp")]
public string? Timestamp { get; set; }
}
public sealed class InclusionProof
{
[JsonPropertyName("leafHash")]
public string? LeafHash { get; set; }
[JsonPropertyName("path")]
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidationResult
{
public AttestorSubmissionValidationResult(byte[] canonicalBundle)
{
CanonicalBundle = canonicalBundle;
}
public byte[] CanonicalBundle { get; }
}

View File

@@ -0,0 +1,167 @@
using System;
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidator
{
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
private readonly IDsseCanonicalizer _canonicalizer;
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer)
{
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
}
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Bundle is null)
{
throw new AttestorValidationException("bundle_missing", "Submission bundle payload is required.");
}
if (request.Bundle.Dsse is null)
{
throw new AttestorValidationException("dsse_missing", "DSSE envelope is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadType))
{
throw new AttestorValidationException("payload_type_missing", "DSSE payloadType is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadBase64))
{
throw new AttestorValidationException("payload_missing", "DSSE payload must be provided.");
}
if (request.Bundle.Dsse.Signatures.Count == 0)
{
throw new AttestorValidationException("signature_missing", "At least one DSSE signature is required.");
}
if (request.Meta is null)
{
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
}
if (request.Meta.Artifact is null)
{
throw new AttestorValidationException("artifact_missing", "Artifact metadata is required.");
}
if (string.IsNullOrWhiteSpace(request.Meta.Artifact.Sha256))
{
throw new AttestorValidationException("artifact_sha_missing", "Artifact sha256 is required.");
}
if (!IsHex(request.Meta.Artifact.Sha256, expectedLength: 64))
{
throw new AttestorValidationException("artifact_sha_invalid", "Artifact sha256 must be a 64-character hex string.");
}
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
{
throw new AttestorValidationException("bundle_sha_missing", "bundleSha256 is required.");
}
if (!IsHex(request.Meta.BundleSha256, expectedLength: 64))
{
throw new AttestorValidationException("bundle_sha_invalid", "bundleSha256 must be a 64-character hex string.");
}
if (Array.IndexOf(AllowedKinds, request.Meta.Artifact.Kind) < 0)
{
throw new AttestorValidationException("artifact_kind_invalid", $"Artifact kind '{request.Meta.Artifact.Kind}' is not supported.");
}
if (!Base64UrlDecode(request.Bundle.Dsse.PayloadBase64, out _))
{
throw new AttestorValidationException("payload_invalid_base64", "DSSE payload must be valid base64.");
}
var canonical = await _canonicalizer.CanonicalizeAsync(request, cancellationToken).ConfigureAwait(false);
Span<byte> hash = stackalloc byte[32];
if (!SHA256.TryHashData(canonical, hash, out _))
{
throw new AttestorValidationException("bundle_sha_failure", "Failed to compute canonical bundle hash.");
}
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(hashHex, request.Meta.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("bundle_sha_mismatch", "bundleSha256 does not match canonical DSSE hash.");
}
if (!string.Equals(request.Meta.LogPreference, "primary", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "mirror", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', or 'both'.");
}
return new AttestorSubmissionValidationResult(canonical);
}
private static bool IsHex(string value, int expectedLength)
{
if (value.Length != expectedLength)
{
return false;
}
foreach (var ch in value)
{
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private static bool Base64UrlDecode(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(Normalise(value));
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
private static string Normalise(string value)
{
if (value.Contains('-') || value.Contains('_'))
{
Span<char> buffer = value.ToCharArray();
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = buffer[i] switch
{
'-' => '+',
'_' => '/',
_ => buffer[i]
};
}
var padding = 4 - (buffer.Length % 4);
return padding == 4 ? new string(buffer) : new string(buffer) + new string('=', padding);
}
return value;
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorValidationException : Exception
{
public AttestorValidationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IAttestorSubmissionService
{
Task<AttestorSubmissionResult> SubmitAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IDsseCanonicalizer
{
Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Ambient information about the caller used for policy and audit decisions.
/// </summary>
public sealed class SubmissionContext
{
public required string CallerSubject { get; init; }
public required string CallerAudience { get; init; }
public required string? CallerClientId { get; init; }
public required string? CallerTenant { get; init; }
public X509Certificate2? ClientCertificate { get; init; }
public string? MtlsThumbprint { get; init; }
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationException : Exception
{
public AttestorVerificationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Attestor.Core.Verification;
/// <summary>
/// Payload accepted by the verification service.
/// </summary>
public sealed class AttestorVerificationRequest
{
public string? Uuid { get; set; }
public Submission.AttestorSubmissionRequest.SubmissionBundle? Bundle { get; set; }
public string? ArtifactSha256 { get; set; }
public bool RefreshProof { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationResult
{
public bool Ok { get; init; }
public string? Uuid { get; init; }
public long? Index { get; init; }
public string? LogUrl { get; init; }
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;
public string Status { get; init; } = "unknown";
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Core.Verification;
public interface IAttestorVerificationService
{
Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default);
Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class HttpRekorClient : IRekorClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly ILogger<HttpRekorClient> _logger;
public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
{
Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
};
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Conflict)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Rekor reported a conflict: {message}");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
long? index = null;
if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
{
index = indexValue;
}
return new RekorSubmissionResponse
{
Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
Index = index,
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
};
}
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Rekor proof for {Uuid} not found", rekorUuid);
return null;
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return TryParseProof(document.RootElement);
}
private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
{
var signatures = new List<object>();
foreach (var sig in request.Bundle.Dsse.Signatures)
{
signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
}
return new
{
entries = new[]
{
new
{
dsseEnvelope = new
{
payload = request.Bundle.Dsse.PayloadBase64,
payloadType = request.Bundle.Dsse.PayloadType,
signatures
}
}
}
};
}
private static RekorProofResponse? TryParseProof(JsonElement proofElement)
{
if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
{
return null;
}
var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
return new RekorProofResponse
{
Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorCheckpoint
{
Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
}
: null,
Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorInclusionProof
{
LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
: Array.Empty<string>()
}
: null
};
}
private static Uri BuildUri(Uri baseUri, string relative)
{
if (!relative.StartsWith("/", StringComparison.Ordinal))
{
relative = "/" + relative;
}
return new Uri(baseUri, relative);
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class StubRekorClient : IRekorClient
{
private readonly ILogger<StubRekorClient> _logger;
public StubRekorClient(ILogger<StubRekorClient> logger)
{
_logger = logger;
}
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var uuid = Guid.NewGuid().ToString();
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
var proof = new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = request.Meta.BundleSha256,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = request.Meta.BundleSha256,
Path = Array.Empty<string>()
}
};
var response = new RekorSubmissionResponse
{
Uuid = uuid,
Index = Random.Shared.NextInt64(1, long.MaxValue),
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
Status = "included",
Proof = proof
};
return Task.FromResult(response);
}
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = string.Empty,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = string.Empty,
Path = Array.Empty<string>()
}
});
}
}

View File

@@ -0,0 +1,118 @@
using System;
using Amazon.Runtime;
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Verification;
namespace StellaOps.Attestor.Infrastructure;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
services.AddSingleton<AttestorSubmissionValidator>();
services.AddSingleton<AttestorMetrics>();
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
services.AddHttpClient<HttpRekorClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
services.AddSingleton<IMongoClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
if (string.IsNullOrWhiteSpace(options.Mongo.Uri))
{
throw new InvalidOperationException("Attestor MongoDB connection string is not configured.");
}
return new MongoClient(options.Mongo.Uri);
});
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
var client = sp.GetRequiredService<IMongoClient>();
var databaseName = MongoUrl.Create(opts.Mongo.Uri).DatabaseName ?? opts.Mongo.Database;
return client.GetDatabase(databaseName);
});
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<MongoAttestorEntryRepository.AttestorEntryDocument>(opts.Mongo.EntriesCollection);
});
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<MongoAttestorAuditSink.AttestorAuditDocument>(opts.Mongo.AuditCollection);
});
services.AddSingleton<IAttestorEntryRepository, MongoAttestorEntryRepository>();
services.AddSingleton<IAttestorAuditSink, MongoAttestorAuditSink>();
services.AddSingleton<IAttestorDedupeStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
if (string.IsNullOrWhiteSpace(options.Redis.Url))
{
return new InMemoryAttestorDedupeStore();
}
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisAttestorDedupeStore(multiplexer, sp.GetRequiredService<IOptions<AttestorOptions>>());
});
services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
if (string.IsNullOrWhiteSpace(options.Redis.Url))
{
throw new InvalidOperationException("Redis connection string is required when redis dedupe is enabled.");
}
return ConnectionMultiplexer.Connect(options.Redis.Url);
});
services.AddSingleton<IAttestorArchiveStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
if (options.S3.Enabled && !string.IsNullOrWhiteSpace(options.S3.Endpoint) && !string.IsNullOrWhiteSpace(options.S3.Bucket))
{
var config = new AmazonS3Config
{
ServiceURL = options.S3.Endpoint,
ForcePathStyle = true,
UseHttp = !options.S3.UseTls
};
var client = new AmazonS3Client(FallbackCredentialsFactory.GetCredentials(), config);
return new S3AttestorArchiveStore(client, sp.GetRequiredService<IOptions<AttestorOptions>>(), sp.GetRequiredService<ILogger<S3AttestorArchiveStore>>());
}
return new NullAttestorArchiveStore(sp.GetRequiredService<ILogger<NullAttestorArchiveStore>>());
});
return services;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
{
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
if (_store.TryGetValue(bundleSha256, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<string?>(entry.Uuid);
}
_store.TryRemove(bundleSha256, out _);
}
return Task.FromResult<string?>(null);
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorAuditSink : IAttestorAuditSink
{
private readonly IMongoCollection<AttestorAuditDocument> _collection;
public MongoAttestorAuditSink(IMongoCollection<AttestorAuditDocument> collection)
{
_collection = collection;
}
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
{
var document = AttestorAuditDocument.FromRecord(record);
return _collection.InsertOneAsync(document, cancellationToken: cancellationToken);
}
internal sealed class AttestorAuditDocument
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("ts")]
public BsonDateTime Timestamp { get; set; } = BsonDateTime.Create(DateTime.UtcNow);
[BsonElement("action")]
public string Action { get; set; } = string.Empty;
[BsonElement("result")]
public string Result { get; set; } = string.Empty;
[BsonElement("rekorUuid")]
public string? RekorUuid { get; set; }
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("artifactSha256")]
public string ArtifactSha256 { get; set; } = string.Empty;
[BsonElement("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[BsonElement("backend")]
public string Backend { get; set; } = string.Empty;
[BsonElement("latencyMs")]
public long LatencyMs { get; set; }
[BsonElement("caller")]
public CallerDocument Caller { get; set; } = new();
[BsonElement("metadata")]
public BsonDocument Metadata { get; set; } = new();
public static AttestorAuditDocument FromRecord(AttestorAuditRecord record)
{
var metadata = new BsonDocument();
foreach (var kvp in record.Metadata)
{
metadata[kvp.Key] = kvp.Value;
}
return new AttestorAuditDocument
{
Id = ObjectId.GenerateNewId(),
Timestamp = BsonDateTime.Create(record.Timestamp.UtcDateTime),
Action = record.Action,
Result = record.Result,
RekorUuid = record.RekorUuid,
Index = record.Index,
ArtifactSha256 = record.ArtifactSha256,
BundleSha256 = record.BundleSha256,
Backend = record.Backend,
LatencyMs = record.LatencyMs,
Caller = new CallerDocument
{
Subject = record.Caller.Subject,
Audience = record.Caller.Audience,
ClientId = record.Caller.ClientId,
MtlsThumbprint = record.Caller.MtlsThumbprint,
Tenant = record.Caller.Tenant
},
Metadata = metadata
};
}
internal sealed class CallerDocument
{
[BsonElement("subject")]
public string? Subject { get; set; }
[BsonElement("audience")]
public string? Audience { get; set; }
[BsonElement("clientId")]
public string? ClientId { get; set; }
[BsonElement("mtlsThumbprint")]
public string? MtlsThumbprint { get; set; }
[BsonElement("tenant")]
public string? Tenant { get; set; }
}
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
{
private readonly IMongoCollection<AttestorEntryDocument> _entries;
public MongoAttestorEntryRepository(IMongoCollection<AttestorEntryDocument> entries)
{
_entries = entries;
}
public async Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.BundleSha256, bundleSha256);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, rekorUuid);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Artifact.Sha256, artifactSha256);
var documents = await _entries.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.ConvertAll(static doc => doc.ToDomain());
}
public async Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
{
var document = AttestorEntryDocument.FromDomain(entry);
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, document.Id);
await _entries.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
[BsonIgnoreExtraElements]
internal sealed class AttestorEntryDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("artifact")]
public ArtifactDocument Artifact { get; set; } = new();
[BsonElement("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("proof")]
public ProofDocument? Proof { get; set; }
[BsonElement("log")]
public LogDocument Log { get; set; } = new();
[BsonElement("createdAt")]
public BsonDateTime CreatedAt { get; set; } = BsonDateTime.Create(System.DateTimeOffset.UtcNow);
[BsonElement("status")]
public string Status { get; set; } = "pending";
[BsonElement("signerIdentity")]
public SignerIdentityDocument SignerIdentity { get; set; } = new();
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
{
return new AttestorEntryDocument
{
Id = entry.RekorUuid,
Artifact = new ArtifactDocument
{
Sha256 = entry.Artifact.Sha256,
Kind = entry.Artifact.Kind,
ImageDigest = entry.Artifact.ImageDigest,
SubjectUri = entry.Artifact.SubjectUri
},
BundleSha256 = entry.BundleSha256,
Index = entry.Index,
Proof = entry.Proof is null ? null : new ProofDocument
{
Checkpoint = entry.Proof.Checkpoint is null ? null : new CheckpointDocument
{
Origin = entry.Proof.Checkpoint.Origin,
Size = entry.Proof.Checkpoint.Size,
RootHash = entry.Proof.Checkpoint.RootHash,
Timestamp = entry.Proof.Checkpoint.Timestamp is null
? null
: BsonDateTime.Create(entry.Proof.Checkpoint.Timestamp.Value)
},
Inclusion = entry.Proof.Inclusion is null ? null : new InclusionDocument
{
LeafHash = entry.Proof.Inclusion.LeafHash,
Path = entry.Proof.Inclusion.Path
}
},
Log = new LogDocument
{
Url = entry.Log.Url,
LogId = entry.Log.LogId
},
CreatedAt = BsonDateTime.Create(entry.CreatedAt.UtcDateTime),
Status = entry.Status,
SignerIdentity = new SignerIdentityDocument
{
Mode = entry.SignerIdentity.Mode,
Issuer = entry.SignerIdentity.Issuer,
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
KeyId = entry.SignerIdentity.KeyId
}
};
}
public AttestorEntry ToDomain()
{
return new AttestorEntry
{
RekorUuid = Id,
Artifact = new AttestorEntry.ArtifactDescriptor
{
Sha256 = Artifact.Sha256,
Kind = Artifact.Kind,
ImageDigest = Artifact.ImageDigest,
SubjectUri = Artifact.SubjectUri
},
BundleSha256 = BundleSha256,
Index = Index,
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
{
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
{
Origin = Proof.Checkpoint.Origin,
Size = Proof.Checkpoint.Size,
RootHash = Proof.Checkpoint.RootHash,
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
},
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
{
LeafHash = Proof.Inclusion.LeafHash,
Path = Proof.Inclusion.Path
}
},
Log = new AttestorEntry.LogDescriptor
{
Url = Log.Url,
LogId = Log.LogId
},
CreatedAt = CreatedAt.ToUniversalTime(),
Status = Status,
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
Mode = SignerIdentity.Mode,
Issuer = SignerIdentity.Issuer,
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
KeyId = SignerIdentity.KeyId
}
};
}
internal sealed class ArtifactDocument
{
[BsonElement("sha256")]
public string Sha256 { get; set; } = string.Empty;
[BsonElement("kind")]
public string Kind { get; set; } = string.Empty;
[BsonElement("imageDigest")]
public string? ImageDigest { get; set; }
[BsonElement("subjectUri")]
public string? SubjectUri { get; set; }
}
internal sealed class ProofDocument
{
[BsonElement("checkpoint")]
public CheckpointDocument? Checkpoint { get; set; }
[BsonElement("inclusion")]
public InclusionDocument? Inclusion { get; set; }
}
internal sealed class CheckpointDocument
{
[BsonElement("origin")]
public string? Origin { get; set; }
[BsonElement("size")]
public long Size { get; set; }
[BsonElement("rootHash")]
public string? RootHash { get; set; }
[BsonElement("timestamp")]
public BsonDateTime? Timestamp { get; set; }
}
internal sealed class InclusionDocument
{
[BsonElement("leafHash")]
public string? LeafHash { get; set; }
[BsonElement("path")]
public IReadOnlyList<string> Path { get; set; } = System.Array.Empty<string>();
}
internal sealed class LogDocument
{
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("logId")]
public string? LogId { get; set; }
}
internal sealed class SignerIdentityDocument
{
[BsonElement("mode")]
public string Mode { get; set; } = string.Empty;
[BsonElement("issuer")]
public string? Issuer { get; set; }
[BsonElement("san")]
public string? SubjectAlternativeName { get; set; }
[BsonElement("kid")]
public string? KeyId { get; set; }
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
{
private readonly ILogger<NullAttestorArchiveStore> _logger;
public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
{
_logger = logger;
}
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
{
private readonly IDatabase _database;
private readonly string _prefix;
public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
{
_database = multiplexer.GetDatabase();
_prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
}
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
return value.HasValue ? value.ToString() : null;
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
}
private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
}

View File

@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposable
{
private readonly IAmazonS3 _s3;
private readonly AttestorOptions.S3Options _options;
private readonly ILogger<S3AttestorArchiveStore> _logger;
private bool _disposed;
public S3AttestorArchiveStore(IAmazonS3 s3, IOptions<AttestorOptions> options, ILogger<S3AttestorArchiveStore> logger)
{
_s3 = s3;
_options = options.Value.S3;
_logger = logger;
}
public async Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_options.Bucket))
{
_logger.LogWarning("S3 archive bucket is not configured; skipping archive for bundle {Bundle}", bundle.BundleSha256);
return;
}
var prefix = _options.Prefix ?? "attest/";
await PutObjectAsync(prefix + "dsse/" + bundle.BundleSha256 + ".json", bundle.CanonicalBundleJson, cancellationToken).ConfigureAwait(false);
if (bundle.ProofJson.Length > 0)
{
await PutObjectAsync(prefix + "proof/" + bundle.RekorUuid + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
}
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(bundle.Metadata);
await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
}
private Task PutObjectAsync(string key, byte[] content, CancellationToken cancellationToken)
{
using var stream = new MemoryStream(content);
var request = new PutObjectRequest
{
BucketName = _options.Bucket,
Key = key,
InputStream = stream,
AutoCloseStream = false
};
return _s3.PutObjectAsync(request, cancellationToken);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_s3.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Submission;
internal sealed class AttestorSubmissionService : IAttestorSubmissionService
{
private static readonly TimeSpan DedupeTtl = TimeSpan.FromHours(48);
private readonly AttestorSubmissionValidator _validator;
private readonly IAttestorEntryRepository _repository;
private readonly IAttestorDedupeStore _dedupeStore;
private readonly IRekorClient _rekorClient;
private readonly IAttestorArchiveStore _archiveStore;
private readonly IAttestorAuditSink _auditSink;
private readonly ILogger<AttestorSubmissionService> _logger;
private readonly TimeProvider _timeProvider;
private readonly AttestorOptions _options;
private readonly AttestorMetrics _metrics;
public AttestorSubmissionService(
AttestorSubmissionValidator validator,
IAttestorEntryRepository repository,
IAttestorDedupeStore dedupeStore,
IRekorClient rekorClient,
IAttestorArchiveStore archiveStore,
IAttestorAuditSink auditSink,
IOptions<AttestorOptions> options,
ILogger<AttestorSubmissionService> logger,
TimeProvider timeProvider,
AttestorMetrics metrics)
{
_validator = validator;
_repository = repository;
_dedupeStore = dedupeStore;
_rekorClient = rekorClient;
_archiveStore = archiveStore;
_auditSink = auditSink;
_logger = logger;
_timeProvider = timeProvider;
_options = options.Value;
_metrics = metrics;
}
public async Task<AttestorSubmissionResult> SubmitAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default)
{
var start = System.Diagnostics.Stopwatch.GetTimestamp();
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
var canonicalBundle = validation.CanonicalBundle;
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(dedupeUuid))
{
_logger.LogInformation("Dedupe hit for bundle {BundleSha256} -> {RekorUuid}", request.Meta.BundleSha256, dedupeUuid);
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
var existing = await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
?? await _repository.GetByBundleShaAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
_metrics.SubmitTotal.Add(1,
new KeyValuePair<string, object?>("result", "dedupe"),
new KeyValuePair<string, object?>("backend", "cache"));
return ToResult(existing);
}
}
else
{
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
}
var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
RekorSubmissionResponse submissionResponse;
try
{
submissionResponse = await _rekorClient.SubmitAsync(request, primaryBackend, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit"));
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, primaryBackend.Name);
throw;
}
var proof = submissionResponse.Proof;
if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
{
try
{
proof = await _rekorClient.GetProofAsync(submissionResponse.Uuid, primaryBackend, cancellationToken).ConfigureAwait(false);
_metrics.ProofFetchTotal.Add(1,
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
}
catch (Exception ex)
{
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submissionResponse.Uuid, primaryBackend.Name);
}
}
var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
if (request.Meta.Archive)
{
var archiveBundle = new AttestorArchiveBundle
{
RekorUuid = entry.RekorUuid,
ArtifactSha256 = entry.Artifact.Sha256,
BundleSha256 = entry.BundleSha256,
CanonicalBundleJson = canonicalBundle,
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
Metadata = new Dictionary<string, string>
{
["logUrl"] = entry.Log.Url,
["status"] = entry.Status
}
};
try
{
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
}
}
var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start, System.Diagnostics.Stopwatch.GetTimestamp());
_metrics.SubmitTotal.Add(1,
new KeyValuePair<string, object?>("result", submissionResponse.Status ?? "unknown"),
new KeyValuePair<string, object?>("backend", primaryBackend.Name));
_metrics.SubmitLatency.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("backend", primaryBackend.Name));
await WriteAuditAsync(request, context, entry, submissionResponse, (long)elapsed.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
return ToResult(entry);
}
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
{
return new AttestorSubmissionResult
{
Uuid = entry.RekorUuid,
Index = entry.Index,
LogUrl = entry.Log.Url,
Status = entry.Status,
Proof = entry.Proof is null ? null : new AttestorSubmissionResult.RekorProof
{
Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
{
Origin = entry.Proof.Checkpoint.Origin,
Size = entry.Proof.Checkpoint.Size,
RootHash = entry.Proof.Checkpoint.RootHash,
Timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
},
Inclusion = entry.Proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
{
LeafHash = entry.Proof.Inclusion.LeafHash,
Path = entry.Proof.Inclusion.Path
}
}
};
}
private AttestorEntry CreateEntry(
AttestorSubmissionRequest request,
RekorSubmissionResponse submission,
RekorProofResponse? proof,
SubmissionContext context,
byte[] canonicalBundle)
{
var now = _timeProvider.GetUtcNow();
return new AttestorEntry
{
RekorUuid = submission.Uuid,
Artifact = new AttestorEntry.ArtifactDescriptor
{
Sha256 = request.Meta.Artifact.Sha256,
Kind = request.Meta.Artifact.Kind,
ImageDigest = request.Meta.Artifact.ImageDigest,
SubjectUri = request.Meta.Artifact.SubjectUri
},
BundleSha256 = request.Meta.BundleSha256,
Index = submission.Index,
Proof = proof is null ? null : new AttestorEntry.ProofDescriptor
{
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
{
Origin = proof.Checkpoint.Origin,
Size = proof.Checkpoint.Size,
RootHash = proof.Checkpoint.RootHash,
Timestamp = proof.Checkpoint.Timestamp
},
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
{
LeafHash = proof.Inclusion.LeafHash,
Path = proof.Inclusion.Path
}
},
Log = new AttestorEntry.LogDescriptor
{
Url = submission.LogUrl ?? string.Empty,
LogId = null
},
CreatedAt = now,
Status = submission.Status ?? "included",
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
Mode = request.Bundle.Mode,
Issuer = context.CallerAudience,
SubjectAlternativeName = context.CallerSubject,
KeyId = context.CallerClientId
}
};
}
private Task WriteAuditAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
AttestorEntry entry,
RekorSubmissionResponse submission,
long latencyMs,
CancellationToken cancellationToken)
{
var record = new AttestorAuditRecord
{
Action = "submit",
Result = submission.Status ?? "included",
RekorUuid = submission.Uuid,
Index = submission.Index,
ArtifactSha256 = request.Meta.Artifact.Sha256,
BundleSha256 = request.Meta.BundleSha256,
Backend = "primary",
LatencyMs = latencyMs,
Timestamp = _timeProvider.GetUtcNow(),
Caller = new AttestorAuditRecord.CallerDescriptor
{
Subject = context.CallerSubject,
Audience = context.CallerAudience,
ClientId = context.CallerClientId,
MtlsThumbprint = context.MtlsThumbprint,
Tenant = context.CallerTenant
}
};
return _auditSink.WriteAsync(record, cancellationToken);
}
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
{
if (string.IsNullOrWhiteSpace(options.Url))
{
throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
}
return new RekorBackend
{
Name = name,
Url = new Uri(options.Url, UriKind.Absolute),
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
MaxAttempts = options.MaxAttempts
};
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Submission;
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
{
var node = new JsonObject
{
["payloadType"] = request.Bundle.Dsse.PayloadType,
["payload"] = request.Bundle.Dsse.PayloadBase64,
["signatures"] = CreateSignaturesArray(request)
};
var json = node.ToJsonString(SerializerOptions);
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
}
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
{
var array = new JsonArray();
foreach (var signature in request.Bundle.Dsse.Signatures)
{
var obj = new JsonObject
{
["sig"] = signature.Signature
};
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
obj["keyid"] = signature.KeyId;
}
array.Add(obj);
}
return array;
}
}

View File

@@ -0,0 +1,261 @@
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.Attestor.Core.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using System.Security.Cryptography;
namespace StellaOps.Attestor.Infrastructure.Verification;
internal sealed class AttestorVerificationService : IAttestorVerificationService
{
private readonly IAttestorEntryRepository _repository;
private readonly IDsseCanonicalizer _canonicalizer;
private readonly IRekorClient _rekorClient;
private readonly ILogger<AttestorVerificationService> _logger;
private readonly AttestorOptions _options;
public AttestorVerificationService(
IAttestorEntryRepository repository,
IDsseCanonicalizer canonicalizer,
IRekorClient rekorClient,
IOptions<AttestorOptions> options,
ILogger<AttestorVerificationService> logger)
{
_repository = repository;
_canonicalizer = canonicalizer;
_rekorClient = rekorClient;
_logger = logger;
_options = options.Value;
}
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var entry = await ResolveEntryAsync(request, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
throw new AttestorVerificationException("not_found", "No attestor entry matched the supplied query.");
}
var issues = new List<string>();
if (request.Bundle is not null)
{
var canonicalBundle = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
{
Bundle = request.Bundle,
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = entry.Artifact.Sha256,
Kind = entry.Artifact.Kind
},
BundleSha256 = entry.BundleSha256
}
}, cancellationToken).ConfigureAwait(false);
var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
issues.Add("Bundle hash does not match stored canonical hash.");
}
}
if (request.RefreshProof || entry.Proof is null)
{
var backend = BuildBackend("primary", _options.Rekor.Primary);
try
{
var proof = await _rekorClient.GetProofAsync(entry.RekorUuid, backend, cancellationToken).ConfigureAwait(false);
if (proof is not null)
{
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
entry = updated;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", entry.RekorUuid);
issues.Add("Proof refresh failed: " + ex.Message);
}
}
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
return new AttestorVerificationResult
{
Ok = ok,
Uuid = entry.RekorUuid,
Index = entry.Index,
LogUrl = entry.Log.Url,
Status = entry.Status,
Issues = issues,
CheckedAt = DateTimeOffset.UtcNow
};
}
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rekorUuid))
{
throw new ArgumentException("Value cannot be null or whitespace.", nameof(rekorUuid));
}
return ResolveEntryByUuidAsync(rekorUuid, refreshProof, cancellationToken);
}
private async Task<AttestorEntry?> ResolveEntryAsync(AttestorVerificationRequest request, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(request.Uuid))
{
return await ResolveEntryByUuidAsync(request.Uuid, request.RefreshProof, cancellationToken).ConfigureAwait(false);
}
if (request.Bundle is not null)
{
var canonical = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
{
Bundle = request.Bundle,
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = string.Empty,
Kind = string.Empty
}
}
}, cancellationToken).ConfigureAwait(false);
var bundleSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonical)).ToLowerInvariant();
return await ResolveEntryByBundleShaAsync(bundleSha, request.RefreshProof, cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(request.ArtifactSha256))
{
return await ResolveEntryByArtifactAsync(request.ArtifactSha256, request.RefreshProof, cancellationToken).ConfigureAwait(false);
}
throw new AttestorVerificationException("invalid_query", "At least one of uuid, bundle, or artifactSha256 must be provided.");
}
private async Task<AttestorEntry?> ResolveEntryByUuidAsync(string uuid, bool refreshProof, CancellationToken cancellationToken)
{
var entry = await _repository.GetByUuidAsync(uuid, cancellationToken).ConfigureAwait(false);
if (entry is null || !refreshProof)
{
return entry;
}
var backend = BuildBackend("primary", _options.Rekor.Primary);
try
{
var proof = await _rekorClient.GetProofAsync(uuid, backend, cancellationToken).ConfigureAwait(false);
if (proof is not null)
{
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
entry = updated;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", uuid);
}
return entry;
}
private async Task<AttestorEntry?> ResolveEntryByBundleShaAsync(string bundleSha, bool refreshProof, CancellationToken cancellationToken)
{
var entry = await _repository.GetByBundleShaAsync(bundleSha, cancellationToken).ConfigureAwait(false);
if (entry is null || !refreshProof)
{
return entry;
}
return await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false);
}
private async Task<AttestorEntry?> ResolveEntryByArtifactAsync(string artifactSha256, bool refreshProof, CancellationToken cancellationToken)
{
var entries = await _repository.GetByArtifactShaAsync(artifactSha256, cancellationToken).ConfigureAwait(false);
var entry = entries.OrderByDescending(e => e.CreatedAt).FirstOrDefault();
if (entry is null)
{
return null;
}
return refreshProof
? await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false)
: entry;
}
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
{
return new AttestorEntry
{
RekorUuid = entry.RekorUuid,
Artifact = entry.Artifact,
BundleSha256 = entry.BundleSha256,
Index = entry.Index,
Proof = proof,
Log = entry.Log,
CreatedAt = entry.CreatedAt,
Status = entry.Status,
SignerIdentity = entry.SignerIdentity
};
}
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
{
if (string.IsNullOrWhiteSpace(options.Url))
{
throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
}
return new RekorBackend
{
Name = name,
Url = new Uri(options.Url, UriKind.Absolute),
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
MaxAttempts = options.MaxAttempts
};
}
}
internal static class RekorProofResponseExtensions
{
public static AttestorEntry.ProofDescriptor ToProofDescriptor(this RekorProofResponse response)
{
return new AttestorEntry.ProofDescriptor
{
Checkpoint = response.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
{
Origin = response.Checkpoint.Origin,
Size = response.Checkpoint.Size,
RootHash = response.Checkpoint.RootHash,
Timestamp = response.Checkpoint.Timestamp
},
Inclusion = response.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
{
LeafHash = response.Inclusion.LeafHash,
Path = response.Inclusion.Path
}
};
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorSubmissionServiceTests
{
[Fact]
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var logger = new NullLogger<AttestorSubmissionService>();
using var metrics = new AttestorMetrics();
var service = new AttestorSubmissionService(
validator,
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default",
ClientCertificate = null,
MtlsThumbprint = "00"
};
var first = await service.SubmitAsync(request, context);
var second = await service.SubmitAsync(request, context);
Assert.NotNull(first.Uuid);
Assert.Equal(first.Uuid, second.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal(first.Uuid, stored!.RekorUuid);
}
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
{
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}

View File

@@ -0,0 +1,194 @@
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Infrastructure.Verification;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Core.Observability;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorVerificationServiceTests
{
[Fact]
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
using var metrics = new AttestorMetrics();
var canonicalizer = new DefaultDsseCanonicalizer();
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var submissionService = new AttestorSubmissionService(
new AttestorSubmissionValidator(canonicalizer),
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
new NullLogger<AttestorSubmissionService>(),
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var response = await submissionService.SubmitAsync(submission, context);
var verificationService = new AttestorVerificationService(
repository,
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid
});
Assert.True(verifyResult.Ok);
Assert.Equal(response.Uuid, verifyResult.Uuid);
Assert.Empty(verifyResult.Issues);
}
[Fact]
public async Task VerifyAsync_FlagsTamperedBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.example/",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
using var metrics = new AttestorMetrics();
var canonicalizer = new DefaultDsseCanonicalizer();
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var submissionService = new AttestorSubmissionService(
new AttestorSubmissionValidator(canonicalizer),
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
new NullLogger<AttestorSubmissionService>(),
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var response = await submissionService.SubmitAsync(submission, context);
var verificationService = new AttestorVerificationService(
repository,
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
var tamperedBundle = submission.Bundle;
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid,
Bundle = tamperedBundle
});
Assert.False(result.Ok);
Assert.Contains(result.Issues, issue => issue.Contains("Bundle hash", StringComparison.OrdinalIgnoreCase));
}
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer)
{
var payload = Encoding.UTF8.GetBytes("{}");
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class HttpRekorClientTests
{
[Fact]
public async Task SubmitAsync_ParsesResponse()
{
var payload = new
{
uuid = "123",
index = 42,
logURL = "https://rekor.example/api/v2/log/entries/123",
status = "included",
proof = new
{
checkpoint = new { origin = "rekor", size = 10, rootHash = "abc", timestamp = "2025-10-19T00:00:00Z" },
inclusion = new { leafHash = "leaf", path = new[] { "p1", "p2" } }
}
};
var client = CreateClient(HttpStatusCode.Created, payload);
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var response = await rekorClient.SubmitAsync(request, backend);
Assert.Equal("123", response.Uuid);
Assert.Equal(42, response.Index);
Assert.Equal("included", response.Status);
Assert.NotNull(response.Proof);
Assert.Equal("leaf", response.Proof!.Inclusion!.LeafHash);
}
[Fact]
public async Task SubmitAsync_ThrowsOnConflict()
{
var client = CreateClient(HttpStatusCode.Conflict, new { error = "duplicate" });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
await Assert.ThrowsAsync<InvalidOperationException>(() => rekorClient.SubmitAsync(request, backend));
}
[Fact]
public async Task GetProofAsync_ReturnsNullOnNotFound()
{
var client = CreateClient(HttpStatusCode.NotFound, new { });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var proof = await rekorClient.GetProofAsync("abc", backend);
Assert.Null(proof);
}
private static HttpClient CreateClient(HttpStatusCode statusCode, object payload)
{
var handler = new StubHandler(statusCode, payload);
return new HttpClient(handler)
{
BaseAddress = new Uri("https://rekor.example/")
};
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly object _payload;
public StubHandler(HttpStatusCode statusCode, object payload)
{
_statusCode = statusCode;
_payload = payload;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(_payload);
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Tests;
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
{
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var entry = _entries.Values.FirstOrDefault(e => string.Equals(e.BundleSha256, bundleSha256, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(entry);
}
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
{
_entries.TryGetValue(rekorUuid, out var entry);
return Task.FromResult(entry);
}
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => e.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
}
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
{
_entries[entry.RekorUuid] = entry;
return Task.CompletedTask;
}
}
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
{
public List<AttestorAuditRecord> Records { get; } = new();
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
{
Records.Add(record);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,234 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using Serilog;
using Serilog.Events;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure;
using StellaOps.Configuration;
using StellaOps.Auth.ServerIntegration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using OpenTelemetry.Metrics;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Verification;
using Microsoft.AspNetCore.Server.Kestrel.Https;
const string ConfigurationSection = "attestor";
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "ATTESTOR_";
options.BindingSection = ConfigurationSection;
});
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddOptions<AttestorOptions>()
.Bind(builder.Configuration.GetSection(ConfigurationSection))
.ValidateOnStart();
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddAttestorInfrastructure();
builder.Services.AddHttpContextAccessor();
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
builder.Services.AddOpenTelemetry()
.WithMetrics(metricsBuilder =>
{
metricsBuilder.AddMeter(AttestorMetrics.MeterName);
metricsBuilder.AddAspNetCoreInstrumentation();
metricsBuilder.AddRuntimeInstrumentation();
});
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authority.Issuer!;
resourceOptions.RequireHttpsMetadata = authority.RequireHttpsMetadata;
if (!string.IsNullOrWhiteSpace(authority.JwksUrl))
{
resourceOptions.MetadataAddress = authority.JwksUrl;
}
foreach (var audience in authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
foreach (var scope in authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("attestor:write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", authority.RequiredScopes);
});
});
}
else
{
builder.Services.AddAuthorization();
}
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
if (attestorOptions.Security.Mtls.RequireClientCertificate)
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
}
});
});
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseExceptionHandler(static handler =>
{
handler.Run(async context =>
{
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await result.ExecuteAsync(context);
});
});
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live");
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
{
var certificate = httpContext.Connection.ClientCertificate;
if (certificate is null)
{
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
}
var user = httpContext.User;
if (user?.Identity is not { IsAuthenticated: true })
{
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
}
var submissionContext = BuildSubmissionContext(user, certificate);
try
{
var result = await submissionService.SubmitAsync(request, submissionContext, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
}
catch (AttestorValidationException validationEx)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: validationEx.Message, extensions: new Dictionary<string, object?>
{
["code"] = validationEx.Code
});
}
})
.RequireAuthorization("attestor:write");
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
{
var entry = await verificationService.GetEntryAsync(uuid, refresh is true, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return Results.NotFound();
}
return Results.Ok(new
{
uuid = entry.RekorUuid,
index = entry.Index,
proof = entry.Proof is null ? null : new
{
checkpoint = entry.Proof.Checkpoint is null ? null : new
{
origin = entry.Proof.Checkpoint.Origin,
size = entry.Proof.Checkpoint.Size,
rootHash = entry.Proof.Checkpoint.RootHash,
timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
},
inclusion = entry.Proof.Inclusion is null ? null : new
{
leafHash = entry.Proof.Inclusion.LeafHash,
path = entry.Proof.Inclusion.Path
}
},
logURL = entry.Log.Url,
status = entry.Status,
artifact = new
{
sha256 = entry.Artifact.Sha256,
kind = entry.Artifact.Kind,
imageDigest = entry.Artifact.ImageDigest,
subjectUri = entry.Artifact.SubjectUri
}
});
}).RequireAuthorization("attestor:write");
app.MapPost("/api/v1/rekor/verify", async (AttestorVerificationRequest request, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
{
try
{
var result = await verificationService.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
}
catch (AttestorVerificationException ex)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, extensions: new Dictionary<string, object?>
{
["code"] = ex.Code
});
}
}).RequireAuthorization("attestor:write");
app.Run();
static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certificate2 certificate)
{
var subject = user.FindFirst("sub")?.Value ?? certificate.Subject;
var audience = user.FindFirst("aud")?.Value ?? string.Empty;
var clientId = user.FindFirst("client_id")?.Value;
var tenant = user.FindFirst("tenant")?.Value;
return new SubmissionContext
{
CallerSubject = subject,
CallerAudience = audience,
CallerClientId = clientId,
CallerTenant = tenant,
ClientCertificate = certificate,
MtlsThumbprint = certificate.Thumbprint
};
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{C0FE77EB-933C-4E47-8195-758AB049157A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj", "{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj", "{B238B098-32B1-4875-99A7-393A63AC3CCF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\StellaOps.Configuration\StellaOps.Configuration.csproj", "{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{82EFA477-307D-4B47-A4CF-1627F076D60A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{21327A4F-2586-49F8-9D4A-3840DE64C48E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.Build.0 = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.Build.0 = Debug|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.Build.0 = Release|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.ActiveCfg = Release|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.Build.0 = Release|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.ActiveCfg = Release|Any CPU
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.Build.0 = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.ActiveCfg = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.Build.0 = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.ActiveCfg = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.Build.0 = Debug|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.Build.0 = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.ActiveCfg = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.Build.0 = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.ActiveCfg = Release|Any CPU
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.Build.0 = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.ActiveCfg = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.Build.0 = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.ActiveCfg = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.Build.0 = Debug|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.Build.0 = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.ActiveCfg = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.Build.0 = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.ActiveCfg = Release|Any CPU
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.Build.0 = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.ActiveCfg = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.Build.0 = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.ActiveCfg = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.Build.0 = Debug|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.Build.0 = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.ActiveCfg = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.Build.0 = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.ActiveCfg = Release|Any CPU
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.Build.0 = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.ActiveCfg = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.Build.0 = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.ActiveCfg = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.Build.0 = Debug|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.Build.0 = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.ActiveCfg = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.Build.0 = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.ActiveCfg = Release|Any CPU
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.Build.0 = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.ActiveCfg = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.Build.0 = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.ActiveCfg = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.Build.0 = Debug|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.Build.0 = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.ActiveCfg = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.Build.0 = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.ActiveCfg = Release|Any CPU
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.Build.0 = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.ActiveCfg = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.Build.0 = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.ActiveCfg = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.Build.0 = Debug|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.Build.0 = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.ActiveCfg = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.Build.0 = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.ActiveCfg = Release|Any CPU
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,11 @@
# Attestor Guild Task Board (UTC 2025-10-19)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| ATTESTOR-API-11-201 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. | ✅ `POST /api/v1/rekor/entries` enforces mTLS + Authority OpTok, validates DSSE bundles, and handles dual-log preferences.<br>✅ Redis/Mongo idempotency returns existing UUID on duplicate `bundleSha256` without re-submitting to Rekor.<br>✅ Rekor driver fetches inclusion proofs (or schedules async fetch) and persists canonical entry/proof metadata.<br>✅ Optional archive path stores DSSE/proof bundles to MinIO/S3; integration tests cover success/pending/error flows. |
| ATTESTOR-VERIFY-11-202 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. | ✅ `GET /api/v1/rekor/entries/{uuid}` surfaces cached entries with optional backend refresh and handles not-found/refresh flows.<br>`POST /api/v1/rekor/verify` accepts UUID, bundle, or artifact hash inputs; verifies DSSE signatures, Merkle proofs, and checkpoint anchors.<br>✅ Verification output returns `{ok, uuid, index, logURL, checkedAt}` with failure diagnostics for invalid proofs.<br>✅ Unit/integration tests exercise cache hits, backend refresh, invalid bundle/proof scenarios, and checkpoint trust anchor enforcement. |
| ATTESTOR-OBS-11-203 | DONE (2025-10-19) | Attestor Guild | — | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. | ✅ Structured logs, metrics, and optional traces record submission latency, proof fetch outcomes, verification results, and Rekor error buckets with correlation IDs.<br>✅ mTLS enforcement hardened (peer allowlist, SAN checks, rate limiting) and documented; TLS settings audited for modern ciphers only.<br>✅ Alerting/dashboard pack covers error rates, proof backlog, Redis/Mongo health, and archive job failures; runbook updated.<br>✅ Archive workflow includes retention policy jobs, failure alerts, and periodic verification of stored bundles and proofs. |
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); Attestor Guild tasks moved to DOING for execution.
> Remark (2025-10-19): `/rekor/entries` submission service implemented with Mongo/Redis persistence, optional S3 archival, Rekor HTTP client, and OpenTelemetry metrics; verification APIs (`/rekor/entries/{uuid}`, `/rekor/verify`) added with proof refresh and canonical hash checks. Remaining: integrate real Rekor endpoints in staging and expand failure-mode tests.
> Remark (2025-10-19): Added Rekor mock client + integration harness to unblock attestor verification testing without external connectivity. Follow-up tasks to wire staging Rekor and record retry/error behavior still pending.

View File

@@ -1,14 +1,15 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
{
private static readonly string ProofType = "dpop+jwt";
@@ -24,10 +25,7 @@ public sealed class DpopProofValidator : IDpopProofValidator
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
@@ -138,11 +136,13 @@ public sealed class DpopProofValidator : IDpopProofValidator
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
if (now - issuedAt > options.ProofLifetime + options.AllowedClockSkew)
if (now - issuedAt > options.GetMaximumAge())
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
}
string? actualNonce = null;
if (nonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
@@ -150,11 +150,17 @@ public sealed class DpopProofValidator : IDpopProofValidator
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
if (!string.Equals(nonceElement.GetString(), nonce, StringComparison.Ordinal))
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
}
}
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
{
actualNonce = nonceElement.GetString();
}
var jwtId = jtiElement.GetString()!;
@@ -185,7 +191,17 @@ public sealed class DpopProofValidator : IDpopProofValidator
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return DpopValidationResult.Success(jwk, jwtId, issuedAt);
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
}
private static string NormalizeHtu(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = null,
Query = null
};
return builder.Uri.ToString();
}
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
@@ -200,16 +216,16 @@ public sealed class DpopProofValidator : IDpopProofValidator
return false;
}
if (segmentIndex < 0 || segmentIndex > 1)
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index must be 0 or 1.";
error = "Segment index out of range.";
return false;
}
try
{
var jsonBytes = Base64UrlEncoder.DecodeBytes(segments[segmentIndex]);
using var document = JsonDocument.Parse(jsonBytes);
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
@@ -220,29 +236,23 @@ public sealed class DpopProofValidator : IDpopProofValidator
}
}
private static string NormalizeHtu(Uri uri)
private static class NullReplayCache
{
var builder = new UriBuilder(uri)
{
Fragment = string.Empty
};
public static readonly IDpopReplayCache Instance = new Noop();
builder.Host = builder.Host.ToLowerInvariant();
builder.Scheme = builder.Scheme.ToLowerInvariant();
if ((builder.Scheme == "http" && builder.Port == 80) || (builder.Scheme == "https" && builder.Port == 443))
private sealed class Noop : IDpopReplayCache
{
builder.Port = -1;
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
return builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
private sealed class NullReplayCache : IDpopReplayCache
{
public static NullReplayCache Instance { get; } = new();
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true);
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -1,8 +1,12 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
@@ -13,14 +17,29 @@ public sealed class DpopValidationOptions
allowedAlgorithms.Add("ES384");
}
/// <summary>
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
/// </summary>
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Allowed clock skew when evaluating <c>iat</c>.
/// </summary>
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Duration a successfully validated proof is tracked to prevent replay.
/// </summary>
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
/// </summary>
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
public void Validate()

View File

@@ -1,10 +1,13 @@
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of DPoP proof validation.
/// </summary>
public sealed class DpopValidationResult
{
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt)
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
@@ -12,6 +15,7 @@ public sealed class DpopValidationResult
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
@@ -26,9 +30,11 @@ public sealed class DpopValidationResult
public DateTimeOffset? IssuedAt { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt)
=> new(true, null, null, key, jwtId, issuedAt);
public string? Nonce { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
=> new(true, null, null, key, jwtId, issuedAt, nonce);
public static DpopValidationResult Failure(string code, string description)
=> new(false, code, description, null, null, null);
=> new(false, code, description, null, null, null, null);
}

View File

@@ -1,7 +1,4 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{

View File

@@ -1,7 +1,4 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{

View File

@@ -1,9 +1,10 @@
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Core.Security;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory replay cache intended for single-process deployments or tests.
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);

View File

@@ -0,0 +1,3 @@
# StellaOps.Auth.Security
Shared sender-constraint helpers (DPoP proof validation, replay caches, future mTLS utilities) used by Authority, Scanner, Signer, and other StellaOps services. This package centralises primitives so services remain deterministic while honouring proof-of-possession guarantees.

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<Description>Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.</Description>
<PackageId>StellaOps.Auth.Security</PackageId>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageTags>stellaops;dpop;mtls;oauth2;security</PackageTags>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageReadmeFile>README.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -46,19 +47,19 @@ public class StandardClientProvisioningStoreTests
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
@@ -69,16 +70,16 @@ public class StandardClientProvisioningStoreTests
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}

View File

@@ -319,13 +319,13 @@ internal sealed class CapturingLoggerProvider : ILoggerProvider
internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
@@ -333,18 +333,18 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}

View File

@@ -9,4 +9,7 @@
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />

View File

@@ -60,6 +60,21 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
document.Properties[key] = value;
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
@@ -147,4 +162,20 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
}

View File

@@ -9,4 +9,5 @@ public static class AuthorityClientMetadataKeys
public const string AllowedScopes = "allowedScopes";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
}

View File

@@ -0,0 +1,45 @@
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Captures certificate metadata associated with an mTLS-bound client.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientCertificateBinding
{
[BsonElement("thumbprint")]
public string Thumbprint { get; set; } = string.Empty;
[BsonElement("serialNumber")]
[BsonIgnoreIfNull]
public string? SerialNumber { get; set; }
[BsonElement("subject")]
[BsonIgnoreIfNull]
public string? Subject { get; set; }
[BsonElement("issuer")]
[BsonIgnoreIfNull]
public string? Issuer { get; set; }
[BsonElement("notBefore")]
public DateTimeOffset? NotBefore { get; set; }
[BsonElement("notAfter")]
public DateTimeOffset? NotAfter { get; set; }
[BsonElement("subjectAlternativeNames")]
public List<string> SubjectAlternativeNames { get; set; } = new();
[BsonElement("label")]
[BsonIgnoreIfNull]
public string? Label { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
@@ -50,6 +51,13 @@ public sealed class AuthorityClientDocument
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("certificateBindings")]
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

View File

@@ -7,6 +7,7 @@ using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Migrations;
using StellaOps.Authority.Storage.Mongo.Options;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
@@ -56,6 +57,8 @@ public static class ServiceCollectionExtensions
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();

View File

@@ -16,7 +16,13 @@ internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectio
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
new CreateIndexOptions { Name = "client_disabled" })
new CreateIndexOptions { Name = "client_disabled" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
new CreateIndexOptions { Name = "client_sender_constraint" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
new CreateIndexOptions { Name = "client_cert_thumbprints" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Options;
namespace StellaOps.Authority.Storage.Mongo.Sessions;
public interface IAuthorityMongoSessionAccessor : IAsyncDisposable
{
ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default);
}
internal sealed class AuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
private readonly IMongoClient client;
private readonly AuthorityMongoOptions options;
private readonly object gate = new();
private Task<IClientSessionHandle>? sessionTask;
private IClientSessionHandle? session;
private bool disposed;
public AuthorityMongoSessionAccessor(
IMongoClient client,
IOptions<AuthorityMongoOptions> options)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(disposed, this);
var existing = Volatile.Read(ref session);
if (existing is not null)
{
return existing;
}
Task<IClientSessionHandle> startTask;
lock (gate)
{
if (session is { } cached)
{
return cached;
}
sessionTask ??= StartSessionInternalAsync(cancellationToken);
startTask = sessionTask;
}
try
{
var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
if (session is null)
{
lock (gate)
{
if (session is null)
{
session = handle;
sessionTask = Task.FromResult(handle);
}
}
}
return handle;
}
catch
{
lock (gate)
{
if (ReferenceEquals(sessionTask, startTask))
{
sessionTask = null;
}
}
throw;
}
}
private async Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
{
var sessionOptions = new ClientSessionOptions
{
CausalConsistency = true,
DefaultTransactionOptions = new TransactionOptions(
readPreference: ReadPreference.Primary,
readConcern: ReadConcern.Majority,
writeConcern: WriteConcern.WMajority.With(wTimeout: options.CommandTimeout))
};
var handle = await client.StartSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
return handle;
}
public ValueTask DisposeAsync()
{
if (disposed)
{
return ValueTask.CompletedTask;
}
disposed = true;
IClientSessionHandle? handle;
lock (gate)
{
handle = session;
session = null;
sessionTask = null;
}
if (handle is not null)
{
handle.Dispose();
}
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
}

View File

@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -12,11 +14,19 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
=> this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return document;
}
@@ -25,7 +35,8 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
string expectedType,
DateTimeOffset now,
string? reservedBy,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
@@ -33,8 +44,9 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
}
var normalizedToken = token.Trim();
var tokenFilter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
tokenFilter,
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
@@ -47,14 +59,31 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
ReturnDocument = ReturnDocument.After
};
var invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
AuthorityBootstrapInviteDocument? invite;
if (session is { })
{
invite = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
}
else
{
invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
}
if (invite is null)
{
var existing = await collection
.Find(i => i.Token == normalizedToken)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
AuthorityBootstrapInviteDocument? existing;
if (session is { })
{
existing = await collection.Find(session, tokenFilter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
existing = await collection.Find(tokenFilter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
if (existing is null)
{
@@ -76,60 +105,76 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
{
await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
await ReleaseAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
}
if (invite.ExpiresAt <= now)
{
await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
await MarkExpiredAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
}
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
}
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var result = await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null);
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.ModifiedCount > 0;
}
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var result = await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
.Set(i => i.ConsumedAt, consumedAt)
.Set(i => i.ConsumedBy, consumedBy),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
.Set(i => i.ConsumedAt, consumedAt)
.Set(i => i.ConsumedBy, consumedBy);
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.ModifiedCount > 0;
}
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
@@ -142,25 +187,49 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null);
var expired = await collection.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
List<AuthorityBootstrapInviteDocument> expired;
if (session is { })
{
expired = await collection.Find(session, filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
expired = await collection.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
if (expired.Count == 0)
{
return Array.Empty<AuthorityBootstrapInviteDocument>();
}
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return expired;
}
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session)
{
await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token);
var update = Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired);
if (session is { })
{
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,7 +22,7 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
@@ -28,12 +30,15 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
var id = clientId.Trim();
return await collection.Find(c => c.ClientId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
var cursor = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -42,7 +47,15 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
@@ -50,7 +63,7 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
}
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
@@ -58,7 +71,18 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
var id = clientId.Trim();
var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,5 +1,9 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -17,11 +21,19 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken)
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug(
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
document.EventType,
@@ -29,7 +41,7 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
document.Outcome);
}
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
{
@@ -38,14 +50,22 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
var normalized = subjectId.Trim();
var cursor = await collection.FindAsync(
Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized),
new FindOptions<AuthorityLoginAttemptDocument>
{
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
Limit = limit
},
cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized);
var options = new FindOptions<AuthorityLoginAttemptDocument>
{
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
Limit = limit
};
IAsyncCursor<AuthorityLoginAttemptDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
}
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
}

View File

@@ -22,10 +22,14 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
@@ -33,7 +37,8 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (newSequence <= 0)
{
@@ -66,7 +71,16 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
try
{
var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
AuthorityRevocationExportStateDocument? result;
if (session is { })
{
result = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
}
if (result is null)
{
throw new InvalidOperationException("Revocation export state update conflict.");

View File

@@ -22,7 +22,7 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -48,10 +48,10 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
var now = DateTimeOffset.UtcNow;
document.UpdatedAt = now;
var existing = await collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (existing is null)
{
@@ -63,11 +63,19 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
document.CreatedAt = existing.CreatedAt;
}
await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var options = new ReplaceOptions { IsUpsert = true };
if (session is { })
{
await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
}
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
{
@@ -78,7 +86,15 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (result.DeletedCount > 0)
{
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
@@ -88,14 +104,17 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
return false;
}
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
var documents = await collection
.Find(filter)
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var documents = await query
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

View File

@@ -1,5 +1,8 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,7 +23,7 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken)
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -28,18 +31,30 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
}
var normalized = name.Trim();
return await collection.Find(s => s.Name == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
IAsyncCursor<AuthorityScopeDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -48,14 +63,23 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
}
}
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -63,7 +87,18 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
}
var normalized = name.Trim();
var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using System.Linq;
using System.Globalization;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -22,15 +24,23 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId);
}
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -38,12 +48,15 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = tokenId.Trim();
return await collection.Find(t => t.TokenId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(referenceId))
{
@@ -51,9 +64,12 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = referenceId.Trim();
return await collection.Find(t => t.ReferenceId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ReferenceId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpdateStatusAsync(
@@ -63,7 +79,8 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
string? reason,
string? reasonDescription,
IReadOnlyDictionary<string, string?>? metadata,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -82,16 +99,29 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
.Set(t => t.RevokedReasonDescription, reasonDescription)
.Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase));
var result = await collection.UpdateOneAsync(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
update,
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim());
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
}
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(
string tokenId,
string? remoteAddress,
string? userAgent,
DateTimeOffset observedAt,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -104,10 +134,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = tokenId.Trim();
var token = await collection
.Find(t => t.TokenId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var token = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (token is null)
{
@@ -147,10 +178,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var update = Builders<AuthorityTokenDocument>.Update.Set(t => t.Devices, token.Devices);
await collection.UpdateOneAsync(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id),
update,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
}
@@ -170,14 +205,22 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
};
}
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityTokenDocument>.Filter.And(
Builders<AuthorityTokenDocument>.Filter.Not(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")),
Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold));
var result = await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteManyAsync(session, filter, options: null, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteManyAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (result.DeletedCount > 0)
{
logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
@@ -186,7 +229,7 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
return result.DeletedCount;
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
@@ -197,8 +240,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
}
var documents = await collection
.Find(filter)
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var documents = await query
.Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,20 +22,23 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
return await collection
.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var normalized = subjectId.Trim();
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalized);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken)
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
@@ -42,13 +47,15 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
var normalised = normalizedUsername.Trim();
return await collection
.Find(u => u.NormalizedUsername == normalised)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.NormalizedUsername, normalised);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -57,9 +64,15 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection
.ReplaceOneAsync(filter, document, options, cancellationToken)
.ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
@@ -67,7 +80,7 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
}
}
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
@@ -75,7 +88,18 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
}
var normalised = subjectId.Trim();
var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalised);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,18 +1,19 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityBootstrapInviteStore
{
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public enum BootstrapInviteReservationStatus

View File

@@ -1,12 +1,13 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityClientStore
{
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken);
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken);
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,10 +1,11 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityLoginAttemptStore
{
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken);
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,18 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityRevocationExportStateStore
{
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken);
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
long expectedSequence,
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
}

View File

@@ -2,15 +2,16 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityRevocationStore
{
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken);
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,14 +1,15 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityScopeStore
{
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken);
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken);
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,16 +1,17 @@
using System;
using System.Collections.Generic;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityTokenStore
{
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken);
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken);
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpdateStatusAsync(
string tokenId,
@@ -19,13 +20,14 @@ public interface IAuthorityTokenStore
string? reason,
string? reasonDescription,
IReadOnlyDictionary<string, string?>? metadata,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public enum TokenUsageUpdateStatus

View File

@@ -1,14 +1,15 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityUserStore
{
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken);
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using MongoDB.Driver;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -65,19 +66,19 @@ public sealed class BootstrapInviteCleanupServiceTests
public bool ExpireCalled { get; private set; }
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotImplementedException();
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken)
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ExpireCalled = true;
return ValueTask.FromResult(invites);

View File

@@ -13,11 +13,13 @@ using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
using Xunit;
using MongoDB.Bson;
using MongoDB.Driver;
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
namespace StellaOps.Authority.Tests.OpenIddict;
@@ -127,6 +129,7 @@ public class ClientCredentialsHandlersTests
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validateHandler = new ValidateClientCredentialsHandler(
@@ -148,10 +151,11 @@ public class ClientCredentialsHandlersTests
var handler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
@@ -202,8 +206,10 @@ public class TokenValidationHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(CreateClient()),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())),
metadataAccessor,
@@ -248,8 +254,10 @@ public class TokenValidationHandlersTests
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
var auditSinkSuccess = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
new TestTokenStore(),
sessionAccessor,
new TestClientStore(clientDocument),
registry,
metadataAccessorSuccess,
@@ -313,8 +321,10 @@ public class TokenValidationHandlersTests
clientDocument.ClientId = "agent";
var auditSink = new TestAuthEventSink();
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
var sessionAccessorReplay = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessorReplay,
new TestClientStore(clientDocument),
registry,
metadataAccessor,
@@ -360,19 +370,19 @@ internal sealed class TestClientStore : IAuthorityClientStore
}
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
@@ -382,28 +392,28 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
public Func<string?, string?, TokenUsageUpdateResult>? UsageCallback { get; set; }
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Inserted = document;
return ValueTask.CompletedTask;
}
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null);
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken)
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0L);
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent));
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
@@ -516,6 +526,14 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IClientSessionHandle>(null!);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
internal static class TestHelpers
{
public static AuthorityClientDocument CreateClient(

View File

@@ -16,6 +16,7 @@ using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Concelier.Testing;
using StellaOps.Authority.RateLimiting;
@@ -59,9 +60,11 @@ public sealed class TokenPersistenceIntegrationTests
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);
@@ -151,8 +154,11 @@ public sealed class TokenPersistenceIntegrationTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
clientStore,
registry,
metadataAccessor,
@@ -249,6 +255,107 @@ public sealed class TokenPersistenceIntegrationTests
});
}
[Fact]
public async Task MongoSessions_ProvideReadYourWriteAfterPrimaryElection()
{
await ResetCollectionsAsync();
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
await using var provider = await BuildMongoProviderAsync(clock);
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var session = await sessionAccessor.GetSessionAsync(CancellationToken.None);
var tokenId = $"election-token-{Guid.NewGuid():N}";
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
SubjectId = "session-subject",
ClientId = "session-client",
Scope = new List<string> { "jobs:read" },
Status = "valid",
CreatedAt = clock.GetUtcNow(),
ExpiresAt = clock.GetUtcNow().AddMinutes(30)
};
await tokenStore.InsertAsync(document, CancellationToken.None, session);
await StepDownPrimaryAsync(fixture.Client, CancellationToken.None);
AuthorityTokenDocument? fetched = null;
for (var attempt = 0; attempt < 5; attempt++)
{
try
{
fetched = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session);
if (fetched is not null)
{
break;
}
}
catch (MongoException)
{
await Task.Delay(250);
}
}
Assert.NotNull(fetched);
Assert.Equal(tokenId, fetched!.TokenId);
}
private static async Task StepDownPrimaryAsync(IMongoClient client, CancellationToken cancellationToken)
{
var admin = client.GetDatabase("admin");
try
{
var command = new BsonDocument
{
{ "replSetStepDown", 5 },
{ "force", true }
};
await admin.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
}
catch (MongoCommandException)
{
// Expected when the current primary steps down.
}
catch (MongoConnectionException)
{
// Connection may drop during election; ignore and continue.
}
await WaitForPrimaryAsync(admin, cancellationToken);
}
private static async Task WaitForPrimaryAsync(IMongoDatabase adminDatabase, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var status = await adminDatabase.RunCommandAsync<BsonDocument>(new BsonDocument { { "replSetGetStatus", 1 } }, cancellationToken: cancellationToken);
if (status.TryGetValue("myState", out var state) && state.ToInt32() == 1)
{
return;
}
}
catch (MongoCommandException)
{
// Ignore intermediate states and retry.
}
await Task.Delay(250, cancellationToken);
}
throw new TimeoutException("Replica set primary election did not complete in time.");
}
private async Task ResetCollectionsAsync()
{
var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);

View File

@@ -9,4 +9,7 @@
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -55,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -377,6 +379,18 @@ Global
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.Build.0 = Release|Any CPU
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.ActiveCfg = Release|Any CPU
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -10,10 +10,12 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
@@ -237,6 +239,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
{
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<HandleClientCredentialsHandler> logger;
@@ -244,12 +247,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
public HandleClientCredentialsHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleClientCredentialsHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -339,7 +344,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
}
await PersistTokenAsync(context, document, tokenId, grantedScopes, activity).ConfigureAwait(false);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
await PersistTokenAsync(context, document, tokenId, grantedScopes, session, activity).ConfigureAwait(false);
context.Principal = principal;
context.HandleRequest();
@@ -388,6 +394,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
AuthorityClientDocument document,
string tokenId,
IReadOnlyCollection<string> scopes,
IClientSessionHandle session,
Activity? activity)
{
if (context.IsRejected)
@@ -413,7 +420,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
ExpiresAt = expiresAt
};
await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false);
await tokenStore.InsertAsync(record, context.CancellationToken, session).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record;
activity?.SetTag("authority.token_id", tokenId);
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -13,17 +14,20 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ILogger<HandleRevocationRequestHandler> logger;
private readonly ActivitySource activitySource;
public HandleRevocationRequestHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleRevocationRequestHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -43,14 +47,15 @@ internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<
}
var token = request.Token.Trim();
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken).ConfigureAwait(false);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken, session).ConfigureAwait(false);
if (document is null)
{
var tokenId = TryExtractTokenId(token);
if (!string.IsNullOrWhiteSpace(tokenId))
{
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken).ConfigureAwait(false);
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken, session).ConfigureAwait(false);
}
}
@@ -70,7 +75,8 @@ internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<
"client_request",
null,
null,
context.CancellationToken).ConfigureAwait(false);
context.CancellationToken,
session).ConfigureAwait(false);
logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId);
activity?.SetTag("authority.token_id", document.TokenId);

View File

@@ -10,7 +10,9 @@ using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -18,17 +20,20 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<PersistTokensHandler> logger;
public PersistTokensHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<PersistTokensHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -47,30 +52,31 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
}
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var issuedAt = clock.GetUtcNow();
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
{
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
{
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
{
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
{
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
}
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, CancellationToken cancellationToken)
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken)
{
var tokenId = EnsureTokenId(principal);
var scopes = ExtractScopes(principal);
@@ -88,7 +94,7 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
try
{
await tokenStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
}
catch (Exception ex)

View File

@@ -8,10 +8,12 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using MongoDB.Driver;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
@@ -20,6 +22,7 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
@@ -30,6 +33,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
public ValidateAccessTokenHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
IAuthorityClientStore clientStore,
IAuthorityIdentityProviderRegistry registry,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
@@ -39,6 +43,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
ILogger<ValidateAccessTokenHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
@@ -74,10 +79,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
? context.TokenId
: context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
AuthorityTokenDocument? tokenDocument = null;
if (!string.IsNullOrWhiteSpace(tokenId))
{
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken).ConfigureAwait(false);
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken, session).ConfigureAwait(false);
if (tokenDocument is not null)
{
if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase))
@@ -101,13 +108,13 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal).ConfigureAwait(false);
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
}
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted.");
@@ -165,14 +172,15 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
private async ValueTask TrackTokenUsageAsync(
OpenIddictServerEvents.ValidateTokenContext context,
AuthorityTokenDocument tokenDocument,
ClaimsPrincipal principal)
ClaimsPrincipal principal,
IClientSessionHandle session)
{
var metadata = metadataAccessor.GetMetadata();
var remoteAddress = metadata?.RemoteIp;
var userAgent = metadata?.UserAgent;
var observedAt = clock.GetUtcNow();
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken)
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken, session)
.ConfigureAwait(false);
switch (result.Status)

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.Logging.Abstractions;
using OpenIddict.Abstractions;
using OpenIddict.Server;
@@ -37,6 +38,9 @@ using StellaOps.Authority.Revocation;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Authority.Storage.Mongo.Documents;
#if STELLAOPS_AUTH_SECURITY
using StellaOps.Auth.Security.Dpop;
#endif
var builder = WebApplication.CreateBuilder(args);
@@ -66,6 +70,15 @@ var authorityConfiguration = StellaOpsAuthorityConfiguration.Build(options =>
};
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
https.CheckCertificateRevocation = true;
});
});
builder.Configuration.AddConfiguration(authorityConfiguration.Configuration);
builder.Host.UseSerilog((context, _, loggerConfiguration) =>
@@ -86,6 +99,28 @@ builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
#if STELLAOPS_AUTH_SECURITY
var senderConstraints = authorityOptions.Security.SenderConstraints;
builder.Services.AddOptions<DpopValidationOptions>()
.Configure(options =>
{
options.ProofLifetime = senderConstraints.Dpop.ProofLifetime;
options.AllowedClockSkew = senderConstraints.Dpop.AllowedClockSkew;
options.ReplayWindow = senderConstraints.Dpop.ReplayWindow;
options.AllowedAlgorithms.Clear();
foreach (var algorithm in senderConstraints.Dpop.NormalizedAlgorithms)
{
options.AllowedAlgorithms.Add(algorithm);
}
})
.PostConfigure(static options => options.Validate());
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
#endif
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions);

View File

@@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DefineConstants>$(DefineConstants);STELLAOPS_AUTH_SECURITY</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
@@ -22,6 +23,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />

View File

@@ -19,6 +19,11 @@
| AUTHCORE-BUILD-OPENIDDICT | DONE (2025-10-14) | Authority Core | SEC2.HOST | Adapt host/audit handlers for OpenIddict 6.4 API surface (no `OpenIddictServerTransaction`) and restore Authority solution build. | ✅ Build `dotnet build src/StellaOps.Authority.sln` succeeds; ✅ Audit correlation + tamper logging verified under new abstractions; ✅ Tests updated. |
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
| AUTHSTORAGE-MONGO-08-001 | TODO | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • DPoP proof validator verifies method/uri/hash, jwk thumbprint, and replay nonce per spec<br>• Nonce issuance endpoint integrated with audit + rate limits; high-value audiences enforce nonce requirement<br>• Integration tests cover success/failure paths (expired nonce, replay, invalid proof) and docs outline operator configuration |
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Client registration stores certificate bindings and enforces SAN/thumbprint validation during token issuance<br>• Token endpoint returns certificate-bound access tokens + PoP proof metadata; introspection reflects binding state<br>• End-to-end tests validate successful mTLS issuance, rejection of unbound certs, and docs capture configuration/rotation guidance |
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. Design blueprint recorded in `docs/dev/authority-dpop-mtls-plan.md`.
> Remark (2025-10-19, AUTH-MTLS-11-002): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. mTLS flow design captured in `docs/dev/authority-dpop-mtls-plan.md`.
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.

Some files were not shown because too many files have changed in this diff Show More