Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

@@ -1,143 +1,143 @@
````markdown
# Feedser Vulnerability Conflict Resolution Rules
This document defines the canonical, deterministic conflict resolution strategy for merging vulnerability data from **NVD**, **GHSA**, and **OSV** in Feedser.
---
## 🧭 Source Precedence
1. **Primary order:**
`GHSA > NVD > OSV`
**Rationale:**
GHSA advisories are human-curated and fast to correct; NVD has the broadest CVE coverage; OSV excels in ecosystem-specific precision.
2. **Freshness override (≥48 h):**
If a **lower-priority** source is **newer by at least 48 hours** for a freshness-sensitive field, its value overrides the higher-priority one.
Always store the decision in a provenance record.
3. **Merge scope:**
Only merge data referring to the **same CVE ID** or the same GHSA/OSV advisory explicitly mapped to that CVE.
---
## 🧩 Field-Level Precedence
| Field | Priority | Freshness-Sensitive | Notes |
|-------|-----------|--------------------|-------|
| Title / Summary | GHSA → NVD → OSV | ✅ | Prefer concise structured titles |
| Description | GHSA → NVD → OSV | ✅ | |
| Severity (CVSS) | NVD → GHSA → OSV | ❌ | Keep all under `metrics[]`, mark `canonicalMetric` by order |
| Ecosystem Severity Label | GHSA → OSV | ❌ | Supplemental tag only |
| Affected Packages / Ranges | OSV → GHSA → NVD | ✅ | OSV strongest for SemVer normalization |
| CWE(s) | NVD → GHSA → OSV | ❌ | NVD taxonomy most stable |
| References / Links | Union of all | ✅ | Deduplicate by normalized URL |
| Credits / Acknowledgements | Union of all | ✅ | Sort by role, displayName |
| Published / Modified timestamps | Earliest published / Latest modified | ✅ | |
| EPSS / KEV / Exploit status | Specialized feed only | ❌ | Do not override manually |
---
## ⚖️ Deterministic Tie-Breakers
If precedence and freshness both tie:
1. **Source order:** GHSA > NVD > OSV
2. **Lexicographic stability:** Prefer shorter normalized text; if equal, ASCIIbetical
3. **Stable hash of payload:** Lowest hash wins
Each chosen value must store the merge rationale:
```json
{
"provenance": {
"source": "GHSA",
"kind": "merge",
"value": "description",
"decisionReason": "precedence"
}
}
````
---
## 🧮 Merge Algorithm (Pseudocode)
```csharp
inputs: records = {ghsa?, nvd?, osv?}
out = new CanonicalVuln(CVE)
foreach field in CANONICAL_SCHEMA:
candidates = collect(values, source, lastModified)
if freshnessSensitive(field) and newerBy48h(lowerPriority):
pick newest
else:
pick by precedence(field)
if tie:
applyTieBreakers()
out.field = normalize(field, value)
out.provenance[field] = decisionTrail
out.references = dedupe(union(all.references))
out.affected = normalizeAndUnion(OSV, GHSA, NVD)
out.metrics = rankAndSetCanonical(NVDv3 → GHSA → OSV → v2)
return out
```
---
## 🔧 Normalization Rules
* **SemVer:**
Parse with tolerant builder; normalize `v` prefixes; map comparators (`<=`, `<`, `>=`, `>`); expand OSV events into continuous ranges.
* **Packages:**
Canonical key = `(ecosystem, packageName, language?)`; maintain aliases (purl, npm, Maven GAV, etc.).
* **CWE:**
Store both ID and name; validate against current CWE catalog.
* **CVSS:**
Preserve provided vector and base score; recompute only for validation.
---
## ✅ Output Guarantees
| Property | Description |
| ---------------- | ------------------------------------------------------------------------------- |
| **Reproducible** | Same input → same canonical output |
| **Auditable** | Provenance stored per field |
| **Complete** | Unions with de-duplication |
| **Composable** | Future layers (KEV, EPSS, vendor advisories) can safely extend precedence rules |
---
## 🧠 Example
* GHSA summary updated on *2025-10-09*
* NVD last modified *2025-10-05*
* OSV updated *2025-10-10*
→ **Summary:** OSV wins (freshness override)
→ **CVSS:** NVD v3.1 remains canonical
→ **Affected:** OSV ranges canonical; GHSA aliases merged
---
## 🧰 Optional C# Helper Class
`StellaOps.Feedser.Core/CanonicalMerger.cs`
Implements:
* `FieldPrecedenceMap`
* `FreshnessSensitiveFields`
* `ApplyTieBreakers()`
* `NormalizeAndUnion()`
Deterministically builds `CanonicalVuln` with full provenance tracking.
```
```
````markdown
# Concelier Vulnerability Conflict Resolution Rules
This document defines the canonical, deterministic conflict resolution strategy for merging vulnerability data from **NVD**, **GHSA**, and **OSV** in Concelier.
---
## 🧭 Source Precedence
1. **Primary order:**
`GHSA > NVD > OSV`
**Rationale:**
GHSA advisories are human-curated and fast to correct; NVD has the broadest CVE coverage; OSV excels in ecosystem-specific precision.
2. **Freshness override (≥48 h):**
If a **lower-priority** source is **newer by at least 48 hours** for a freshness-sensitive field, its value overrides the higher-priority one.
Always store the decision in a provenance record.
3. **Merge scope:**
Only merge data referring to the **same CVE ID** or the same GHSA/OSV advisory explicitly mapped to that CVE.
---
## 🧩 Field-Level Precedence
| Field | Priority | Freshness-Sensitive | Notes |
|-------|-----------|--------------------|-------|
| Title / Summary | GHSA → NVD → OSV | ✅ | Prefer concise structured titles |
| Description | GHSA → NVD → OSV | ✅ | |
| Severity (CVSS) | NVD → GHSA → OSV | ❌ | Keep all under `metrics[]`, mark `canonicalMetric` by order |
| Ecosystem Severity Label | GHSA → OSV | ❌ | Supplemental tag only |
| Affected Packages / Ranges | OSV → GHSA → NVD | ✅ | OSV strongest for SemVer normalization |
| CWE(s) | NVD → GHSA → OSV | ❌ | NVD taxonomy most stable |
| References / Links | Union of all | ✅ | Deduplicate by normalized URL |
| Credits / Acknowledgements | Union of all | ✅ | Sort by role, displayName |
| Published / Modified timestamps | Earliest published / Latest modified | ✅ | |
| EPSS / KEV / Exploit status | Specialized feed only | ❌ | Do not override manually |
---
## ⚖️ Deterministic Tie-Breakers
If precedence and freshness both tie:
1. **Source order:** GHSA > NVD > OSV
2. **Lexicographic stability:** Prefer shorter normalized text; if equal, ASCIIbetical
3. **Stable hash of payload:** Lowest hash wins
Each chosen value must store the merge rationale:
```json
{
"provenance": {
"source": "GHSA",
"kind": "merge",
"value": "description",
"decisionReason": "precedence"
}
}
````
---
## 🧮 Merge Algorithm (Pseudocode)
```csharp
inputs: records = {ghsa?, nvd?, osv?}
out = new CanonicalVuln(CVE)
foreach field in CANONICAL_SCHEMA:
candidates = collect(values, source, lastModified)
if freshnessSensitive(field) and newerBy48h(lowerPriority):
pick newest
else:
pick by precedence(field)
if tie:
applyTieBreakers()
out.field = normalize(field, value)
out.provenance[field] = decisionTrail
out.references = dedupe(union(all.references))
out.affected = normalizeAndUnion(OSV, GHSA, NVD)
out.metrics = rankAndSetCanonical(NVDv3 → GHSA → OSV → v2)
return out
```
---
## 🔧 Normalization Rules
* **SemVer:**
Parse with tolerant builder; normalize `v` prefixes; map comparators (`<=`, `<`, `>=`, `>`); expand OSV events into continuous ranges.
* **Packages:**
Canonical key = `(ecosystem, packageName, language?)`; maintain aliases (purl, npm, Maven GAV, etc.).
* **CWE:**
Store both ID and name; validate against current CWE catalog.
* **CVSS:**
Preserve provided vector and base score; recompute only for validation.
---
## ✅ Output Guarantees
| Property | Description |
| ---------------- | ------------------------------------------------------------------------------- |
| **Reproducible** | Same input → same canonical output |
| **Auditable** | Provenance stored per field |
| **Complete** | Unions with de-duplication |
| **Composable** | Future layers (KEV, EPSS, vendor advisories) can safely extend precedence rules |
---
## 🧠 Example
* GHSA summary updated on *2025-10-09*
* NVD last modified *2025-10-05*
* OSV updated *2025-10-10*
→ **Summary:** OSV wins (freshness override)
→ **CVSS:** NVD v3.1 remains canonical
→ **Affected:** OSV ranges canonical; GHSA aliases merged
---
## 🧰 Optional C# Helper Class
`StellaOps.Concelier.Core/CanonicalMerger.cs`
Implements:
* `FieldPrecedenceMap`
* `FreshnessSensitiveFields`
* `ApplyTieBreakers()`
* `NormalizeAndUnion()`
Deterministically builds `CanonicalVuln` with full provenance tracking.
```
```

View File

@@ -1,33 +1,45 @@
<Project>
<PropertyGroup>
<FeedserPluginOutputRoot Condition="'$(FeedserPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries</FeedserPluginOutputRoot>
<FeedserPluginOutputRoot Condition="'$(FeedserPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries</FeedserPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries\Authority</AuthorityPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries\Authority</AuthorityPluginOutputRoot>
<IsFeedserPlugin Condition="'$(IsFeedserPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Feedser.Source.'))">true</IsFeedserPlugin>
<IsFeedserPlugin Condition="'$(IsFeedserPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Feedser.Exporter.'))">true</IsFeedserPlugin>
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == ''">$(SolutionDir)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot>
<ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == ''">$(SolutionDir)StellaOps.Authority.PluginBinaries</AuthorityPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)StellaOps.Authority.PluginBinaries</AuthorityPluginOutputRoot>
<IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Connector.'))">true</IsConcelierPlugin>
<IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Exporter.'))">true</IsConcelierPlugin>
<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>
<ProjectReference Update="../StellaOps.Plugin/StellaOps.Plugin.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Update="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="SharpCompress" Version="0.41.0" />
</ItemGroup>
<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" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Tests.Shared\AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Tests.Shared\MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" />
<ProjectReference Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Testing\StellaOps.Feedser.Testing.csproj" />
<Using Include="StellaOps.Feedser.Testing" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" />
<ProjectReference Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Using Include="StellaOps.Concelier.Testing" />
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -1,18 +1,18 @@
<Project>
<Target Name="FeedserCopyPluginArtifacts" AfterTargets="Build" Condition="'$(IsFeedserPlugin)' == 'true'">
<Target Name="ConcelierCopyPluginArtifacts" AfterTargets="Build" Condition="'$(IsConcelierPlugin)' == 'true'">
<PropertyGroup>
<FeedserPluginOutputDirectory>$(FeedserPluginOutputRoot)\$(MSBuildProjectName)</FeedserPluginOutputDirectory>
<ConcelierPluginOutputDirectory>$(ConcelierPluginOutputRoot)\$(MSBuildProjectName)</ConcelierPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(FeedserPluginOutputDirectory)" />
<MakeDir Directories="$(ConcelierPluginOutputDirectory)" />
<ItemGroup>
<FeedserPluginArtifacts Include="$(TargetPath)" />
<FeedserPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<FeedserPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<ConcelierPluginArtifacts Include="$(TargetPath)" />
<ConcelierPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<ConcelierPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</ItemGroup>
<Copy SourceFiles="@(FeedserPluginArtifacts)" DestinationFolder="$(FeedserPluginOutputDirectory)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(ConcelierPluginArtifacts)" DestinationFolder="$(ConcelierPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
<Target Name="AuthorityCopyPluginArtifacts" AfterTargets="Build" Condition="'$(IsAuthorityPlugin)' == 'true'">
@@ -30,4 +30,55 @@
<Copy SourceFiles="@(AuthorityPluginArtifacts)" DestinationFolder="$(AuthorityPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
<Target Name="ScannerCopyBuildxPluginArtifacts" AfterTargets="Build" Condition="'$(IsScannerBuildxPlugin)' == 'true'">
<PropertyGroup>
<ScannerBuildxPluginOutputDirectory>$(ScannerBuildxPluginOutputRoot)\$(MSBuildProjectName)</ScannerBuildxPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(ScannerBuildxPluginOutputDirectory)" />
<ItemGroup>
<ScannerBuildxPluginArtifacts Include="$(TargetPath)" />
<ScannerBuildxPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<ScannerBuildxPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<ScannerBuildxPluginArtifacts Include="$(ProjectDir)stellaops.sbom-indexer.manifest.json" Condition="Exists('$(ProjectDir)stellaops.sbom-indexer.manifest.json')" />
</ItemGroup>
<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,148 @@
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 IList<string> AllowedSubjects { get; set; } = new List<string>();
public IList<string> AllowedThumbprints { get; set; } = new List<string>();
}
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

@@ -1,9 +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>
<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,105 @@
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 LogReplicaDescriptor? Mirror { get; init; }
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 Backend { get; init; } = "primary";
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; }
}
public sealed class LogReplicaDescriptor
{
public string Backend { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
public string? Uuid { get; init; }
public long? Index { get; init; }
public string Status { get; init; } = "pending";
public ProofDescriptor? Proof { get; init; }
public string? LogId { get; init; }
public string? Error { 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,83 @@
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";
[JsonPropertyName("mirror")]
public MirrorLog? Mirror { get; set; }
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>();
}
public sealed class MirrorLog
{
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
[JsonPropertyName("proof")]
public RekorProof? Proof { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
}
}

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,176 @@
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;
private readonly HashSet<string> _allowedModes;
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer, IEnumerable<string>? allowedModes = null)
{
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
_allowedModes = allowedModes is null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(allowedModes, StringComparer.OrdinalIgnoreCase);
}
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 (_allowedModes.Count > 0 && !string.IsNullOrWhiteSpace(request.Bundle.Mode) && !_allowedModes.Contains(request.Bundle.Mode))
{
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
}
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,123 @@
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(sp =>
{
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
});
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,342 @@
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();
[BsonElement("mirror")]
public MirrorDocument? Mirror { get; set; }
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
{
Backend = entry.Log.Backend,
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
},
Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
};
}
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
{
Backend = Log.Backend,
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
},
Mirror = Mirror?.ToDomain()
};
}
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("backend")]
public string Backend { get; set; } = "primary";
[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; }
}
internal sealed class MirrorDocument
{
[BsonElement("backend")]
public string Backend { get; set; } = string.Empty;
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("uuid")]
public string? Uuid { get; set; }
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "pending";
[BsonElement("proof")]
public ProofDocument? Proof { get; set; }
[BsonElement("logId")]
public string? LogId { get; set; }
[BsonElement("error")]
public string? Error { get; set; }
public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
{
return new MirrorDocument
{
Backend = mirror.Backend,
Url = mirror.Url,
Uuid = mirror.Uuid,
Index = mirror.Index,
Status = mirror.Status,
Proof = mirror.Proof is null ? null : new ProofDocument
{
Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
{
Origin = mirror.Proof.Checkpoint.Origin,
Size = mirror.Proof.Checkpoint.Size,
RootHash = mirror.Proof.Checkpoint.RootHash,
Timestamp = mirror.Proof.Checkpoint.Timestamp is null
? null
: BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
},
Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
{
LeafHash = mirror.Proof.Inclusion.LeafHash,
Path = mirror.Proof.Inclusion.Path
}
},
LogId = mirror.LogId,
Error = mirror.Error
};
}
public AttestorEntry.LogReplicaDescriptor ToDomain()
{
return new AttestorEntry.LogReplicaDescriptor
{
Backend = Backend,
Url = Url,
Uuid = Uuid,
Index = Index,
Status = Status,
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
}
},
LogId = LogId,
Error = Error
};
}
}
}
}

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,624 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
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)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(context);
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
var canonicalBundle = validation.CanonicalBundle;
var preference = NormalizeLogPreference(request.Meta.LogPreference);
var requiresPrimary = preference is "primary" or "both";
var requiresMirror = preference is "mirror" or "both";
if (!requiresPrimary && !requiresMirror)
{
requiresPrimary = true;
}
if (requiresMirror && !_options.Rekor.Mirror.Enabled)
{
throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
}
var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
return ToResult(updated);
}
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
SubmissionOutcome? canonicalOutcome = null;
SubmissionOutcome? mirrorOutcome = null;
if (requiresPrimary)
{
canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
}
if (requiresMirror)
{
try
{
var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
if (canonicalOutcome is null)
{
canonicalOutcome = mirror;
}
else
{
mirrorOutcome = mirror;
}
}
catch (Exception ex)
{
if (canonicalOutcome is null)
{
throw;
}
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
_logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
RecordSubmissionMetrics(mirrorOutcome);
}
}
if (canonicalOutcome is null)
{
throw new InvalidOperationException("No Rekor submission outcome was produced.");
}
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
if (request.Meta.Archive)
{
await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
}
await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
if (mirrorOutcome is not null)
{
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
}
return ToResult(entry);
}
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
{
var result = new AttestorSubmissionResult
{
Uuid = entry.RekorUuid,
Index = entry.Index,
LogUrl = entry.Log.Url,
Status = entry.Status,
Proof = ToResultProof(entry.Proof)
};
if (entry.Mirror is not null)
{
result.Mirror = new AttestorSubmissionResult.MirrorLog
{
Uuid = entry.Mirror.Uuid,
Index = entry.Mirror.Index,
LogUrl = entry.Mirror.Url,
Status = entry.Mirror.Status,
Proof = ToResultProof(entry.Mirror.Proof),
Error = entry.Mirror.Error
};
}
return result;
}
private AttestorEntry CreateEntry(
AttestorSubmissionRequest request,
SubmissionContext context,
SubmissionOutcome canonicalOutcome,
SubmissionOutcome? mirrorOutcome)
{
if (canonicalOutcome.Submission is null)
{
throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
}
var submission = canonicalOutcome.Submission;
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 = ConvertProof(canonicalOutcome.Proof),
Log = new AttestorEntry.LogDescriptor
{
Backend = canonicalOutcome.Backend,
Url = submission.LogUrl ?? canonicalOutcome.Url,
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
},
Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
};
}
private static string NormalizeLogPreference(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "primary";
}
var normalized = value.Trim().ToLowerInvariant();
return normalized switch
{
"primary" => "primary",
"mirror" => "mirror",
"both" => "both",
_ => "primary"
};
}
private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
{
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(dedupeUuid))
{
return null;
}
return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
}
private async Task<AttestorEntry> EnsureBackendsAsync(
AttestorEntry existing,
AttestorSubmissionRequest request,
SubmissionContext context,
bool requiresPrimary,
bool requiresMirror,
CancellationToken cancellationToken)
{
var entry = existing;
var updated = false;
if (requiresPrimary && !IsPrimary(entry))
{
var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
entry = PromoteToPrimary(entry, outcome);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
updated = true;
}
if (requiresMirror)
{
var mirrorSatisfied = entry.Mirror is not null
&& entry.Mirror.Error is null
&& string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(entry.Mirror.Uuid);
if (!mirrorSatisfied)
{
try
{
var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
entry = WithMirror(entry, mirrorOutcome);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
updated = true;
}
catch (Exception ex)
{
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
_logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
RecordSubmissionMetrics(failure);
entry = WithMirror(entry, failure);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
updated = true;
}
}
}
if (!updated)
{
_metrics.SubmitTotal.Add(1,
new KeyValuePair<string, object?>("result", "dedupe"),
new KeyValuePair<string, object?>("backend", "cache"));
}
return entry;
}
private static bool IsPrimary(AttestorEntry entry) =>
string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
private async Task<SubmissionOutcome> SubmitToBackendAsync(
AttestorSubmissionRequest request,
string backendName,
AttestorOptions.RekorBackendOptions backendOptions,
CancellationToken cancellationToken)
{
var backend = BuildBackend(backendName, backendOptions);
var stopwatch = Stopwatch.StartNew();
try
{
var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var proof = submission.Proof;
if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
{
try
{
proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, 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}", submission.Uuid, backendName);
}
}
var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
RecordSubmissionMetrics(outcome);
return outcome;
}
catch (Exception ex)
{
stopwatch.Stop();
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
throw;
}
}
private void RecordSubmissionMetrics(SubmissionOutcome outcome)
{
var result = outcome.IsSuccess
? outcome.Submission!.Status ?? "unknown"
: "failed";
_metrics.SubmitTotal.Add(1,
new KeyValuePair<string, object?>("result", result),
new KeyValuePair<string, object?>("backend", outcome.Backend));
if (outcome.Latency > TimeSpan.Zero)
{
_metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
new KeyValuePair<string, object?>("backend", outcome.Backend));
}
}
private async Task ArchiveAsync(
AttestorEntry entry,
byte[] canonicalBundle,
RekorProofResponse? proof,
CancellationToken cancellationToken)
{
var metadata = new Dictionary<string, string>
{
["logUrl"] = entry.Log.Url,
["status"] = entry.Status
};
if (entry.Mirror is not null)
{
metadata["mirror.backend"] = entry.Mirror.Backend;
metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
metadata["mirror.status"] = entry.Mirror.Status;
}
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 = metadata
};
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"));
}
}
private Task WriteAuditAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
AttestorEntry entry,
SubmissionOutcome outcome,
CancellationToken cancellationToken)
{
var metadata = new Dictionary<string, string>();
if (!outcome.IsSuccess && outcome.Error is not null)
{
metadata["error"] = outcome.Error.Message;
}
var record = new AttestorAuditRecord
{
Action = "submit",
Result = outcome.IsSuccess
? outcome.Submission!.Status ?? "included"
: "failed",
RekorUuid = outcome.IsSuccess
? outcome.Submission!.Uuid
: string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
? entry.RekorUuid
: entry.Mirror?.Uuid,
Index = outcome.Submission?.Index,
ArtifactSha256 = request.Meta.Artifact.Sha256,
BundleSha256 = request.Meta.BundleSha256,
Backend = outcome.Backend,
LatencyMs = (long)outcome.Latency.TotalMilliseconds,
Timestamp = _timeProvider.GetUtcNow(),
Caller = new AttestorAuditRecord.CallerDescriptor
{
Subject = context.CallerSubject,
Audience = context.CallerAudience,
ClientId = context.CallerClientId,
MtlsThumbprint = context.MtlsThumbprint,
Tenant = context.CallerTenant
},
Metadata = metadata
};
return _auditSink.WriteAsync(record, cancellationToken);
}
private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
{
if (proof is null)
{
return null;
}
return 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
}
};
}
private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
{
if (proof is null)
{
return null;
}
return new AttestorSubmissionResult.RekorProof
{
Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
{
Origin = proof.Checkpoint.Origin,
Size = proof.Checkpoint.Size,
RootHash = proof.Checkpoint.RootHash,
Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
},
Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
{
LeafHash = proof.Inclusion.LeafHash,
Path = proof.Inclusion.Path
}
};
}
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
{
return new AttestorEntry.LogReplicaDescriptor
{
Backend = outcome.Backend,
Url = outcome.IsSuccess
? outcome.Submission!.LogUrl ?? outcome.Url
: outcome.Url,
Uuid = outcome.Submission?.Uuid,
Index = outcome.Submission?.Index,
Status = outcome.IsSuccess
? outcome.Submission!.Status ?? "included"
: "failed",
Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
Error = outcome.Error?.Message
};
}
private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
{
return new AttestorEntry
{
RekorUuid = entry.RekorUuid,
Artifact = entry.Artifact,
BundleSha256 = entry.BundleSha256,
Index = entry.Index,
Proof = entry.Proof,
Log = entry.Log,
CreatedAt = entry.CreatedAt,
Status = entry.Status,
SignerIdentity = entry.SignerIdentity,
Mirror = CreateMirrorDescriptor(outcome)
};
}
private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
{
if (outcome.Submission is null)
{
throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
}
var mirrorDescriptor = existing.Mirror;
if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
{
mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
}
return new AttestorEntry
{
RekorUuid = outcome.Submission.Uuid,
Artifact = existing.Artifact,
BundleSha256 = existing.BundleSha256,
Index = outcome.Submission.Index,
Proof = ConvertProof(outcome.Proof),
Log = new AttestorEntry.LogDescriptor
{
Backend = outcome.Backend,
Url = outcome.Submission.LogUrl ?? outcome.Url,
LogId = existing.Log.LogId
},
CreatedAt = existing.CreatedAt,
Status = outcome.Submission.Status ?? "included",
SignerIdentity = existing.SignerIdentity,
Mirror = mirrorDescriptor
};
}
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
{
return new AttestorEntry.LogReplicaDescriptor
{
Backend = entry.Log.Backend,
Url = entry.Log.Url,
Uuid = entry.RekorUuid,
Index = entry.Index,
Status = entry.Status,
Proof = entry.Proof,
LogId = entry.Log.LogId
};
}
private sealed record SubmissionOutcome(
string Backend,
string Url,
RekorSubmissionResponse? Submission,
RekorProofResponse? Proof,
TimeSpan Latency,
Exception? Error)
{
public bool IsSuccess => Submission is not null && Error is null;
public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
}
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,754 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
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 StellaOps.Attestor.Core.Observability;
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;
private readonly AttestorMetrics _metrics;
public AttestorVerificationService(
IAttestorEntryRepository repository,
IDsseCanonicalizer canonicalizer,
IRekorClient rekorClient,
IOptions<AttestorOptions> options,
ILogger<AttestorVerificationService> logger,
AttestorMetrics metrics)
{
_repository = repository;
_canonicalizer = canonicalizer;
_rekorClient = rekorClient;
_logger = logger;
_options = options.Value;
_metrics = metrics;
}
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(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
issues.Add("bundle_hash_mismatch");
}
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
{
issues.Add("bundle_payload_invalid_base64");
}
else
{
var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
VerifySignatures(entry, request.Bundle, preAuth, issues);
}
}
else
{
_logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
}
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);
}
}
VerifyMerkleProof(entry, issues);
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
_metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
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 void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
{
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
if (mode == "kms")
{
if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
{
issues.Add("signature_invalid_kms");
}
return;
}
if (mode == "keyless")
{
VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
return;
}
issues.Add(string.IsNullOrEmpty(mode)
? "signer_mode_unknown"
: $"signer_mode_unsupported:{mode}");
}
private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
{
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
{
issues.Add("kms_key_missing");
return false;
}
var signatures = new List<byte[]>();
foreach (var signature in bundle.Dsse.Signatures)
{
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
{
issues.Add("signature_invalid_base64");
return false;
}
signatures.Add(signatureBytes);
}
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
{
if (!TryDecodeSecret(secret, out var secretBytes))
{
continue;
}
using var hmac = new HMACSHA256(secretBytes);
var computed = hmac.ComputeHash(preAuthEncoding);
foreach (var signatureBytes in signatures)
{
if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
{
return true;
}
}
}
return false;
}
private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
{
if (bundle.CertificateChain.Count == 0)
{
issues.Add("certificate_chain_missing");
return;
}
var certificates = new List<X509Certificate2>();
try
{
foreach (var pem in bundle.CertificateChain)
{
certificates.Add(X509Certificate2.CreateFromPem(pem));
}
}
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
issues.Add("certificate_chain_invalid");
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
return;
}
var leafCertificate = certificates[0];
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
VerificationFlags = X509VerificationFlags.NoFlag,
TrustMode = X509ChainTrustMode.CustomRootTrust
}
};
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
{
try
{
if (File.Exists(rootPath))
{
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
}
}
if (!chain.Build(leafCertificate))
{
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
.Trim(';');
issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
}
}
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
{
var sans = GetSubjectAlternativeNames(leafCertificate);
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
{
issues.Add("certificate_san_untrusted");
}
}
var signatureVerified = false;
foreach (var signature in bundle.Dsse.Signatures)
{
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
{
issues.Add("signature_invalid_base64");
return;
}
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
{
signatureVerified = true;
break;
}
}
if (!signatureVerified)
{
issues.Add("signature_invalid");
}
}
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
{
try
{
var ecdsa = certificate.GetECDsaPublicKey();
if (ecdsa is not null)
{
using (ecdsa)
{
return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
}
}
var rsa = certificate.GetRSAPublicKey();
if (rsa is not null)
{
using (rsa)
{
return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
}
}
catch (CryptographicException)
{
return false;
}
return false;
}
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
{
foreach (var extension in certificate.Extensions)
{
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
{
continue;
}
var formatted = extension.Format(true);
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var parts = line.Split('=');
if (parts.Length == 2)
{
yield return parts[1].Trim();
}
}
}
}
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
{
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
var offset = 0;
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
offset += 6;
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
offset += 8;
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
offset += headerBytes.Length;
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
offset += 8;
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
return buffer;
}
private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
{
if (entry.Proof is null)
{
issues.Add("proof_missing");
return;
}
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
{
issues.Add("bundle_hash_decode_failed");
return;
}
if (entry.Proof.Inclusion is null)
{
issues.Add("proof_inclusion_missing");
return;
}
if (entry.Proof.Inclusion.LeafHash is not null)
{
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
{
issues.Add("proof_leafhash_decode_failed");
return;
}
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
{
issues.Add("proof_leafhash_mismatch");
}
}
var current = bundleHash;
if (entry.Proof.Inclusion.Path.Count > 0)
{
var nodes = new List<ProofPathNode>();
foreach (var element in entry.Proof.Inclusion.Path)
{
if (!ProofPathNode.TryParse(element, out var node))
{
issues.Add("proof_path_decode_failed");
return;
}
if (!node.HasOrientation)
{
issues.Add("proof_path_orientation_missing");
return;
}
nodes.Add(node);
}
foreach (var node in nodes)
{
current = node.Left
? HashInternal(node.Hash, current)
: HashInternal(current, node.Hash);
}
}
if (entry.Proof.Checkpoint is null)
{
issues.Add("checkpoint_missing");
return;
}
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
{
issues.Add("checkpoint_root_decode_failed");
return;
}
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
{
issues.Add("proof_root_mismatch");
}
}
private static byte[] HashInternal(byte[] left, byte[] right)
{
using var sha = SHA256.Create();
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = 0x01;
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
return sha.ComputeHash(buffer);
}
private static bool TryDecodeSecret(string value, out byte[] bytes)
{
if (string.IsNullOrWhiteSpace(value))
{
bytes = Array.Empty<byte>();
return false;
}
value = value.Trim();
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
{
return TryDecodeBase64(value[7..], out bytes);
}
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
{
return TryDecodeHex(value[4..], out bytes);
}
if (TryDecodeBase64(value, out bytes))
{
return true;
}
if (TryDecodeHex(value, out bytes))
{
return true;
}
bytes = Array.Empty<byte>();
return false;
}
private static bool TryDecodeBase64(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
private static bool TryDecodeHex(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromHexString(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
private static bool TryDecodeHash(string? value, out byte[] bytes)
{
bytes = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (TryDecodeHex(trimmed, out bytes))
{
return true;
}
if (TryDecodeBase64(trimmed, out bytes))
{
return true;
}
bytes = Array.Empty<byte>();
return false;
}
private readonly struct ProofPathNode
{
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
{
HasOrientation = hasOrientation;
Left = left;
Hash = hash;
}
public bool HasOrientation { get; }
public bool Left { get; }
public byte[] Hash { get; }
public static bool TryParse(string value, out ProofPathNode node)
{
node = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
var parts = trimmed.Split(':', 2);
bool hasOrientation = false;
bool left = false;
string hashPart = trimmed;
if (parts.Length == 2)
{
var prefix = parts[0].Trim().ToLowerInvariant();
if (prefix is "l" or "left")
{
hasOrientation = true;
left = true;
}
else if (prefix is "r" or "right")
{
hasOrientation = true;
left = false;
}
hashPart = parts[1].Trim();
}
if (!TryDecodeHash(hashPart, out var hash))
{
return false;
}
node = new ProofPathNode(hasOrientation, left, hash);
return true;
}
}
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,321 @@
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);
}
[Fact]
public async Task Validator_ThrowsWhenModeNotAllowed()
{
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
var request = CreateValidRequest(canonicalizer);
request.Bundle.Mode = "keyless";
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
}
[Fact]
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.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);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
Assert.Equal("mirror_disabled", ex.Code);
}
[Fact]
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.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);
request.Meta.LogPreference = "both";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Mirror);
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
Assert.Equal("included", result.Mirror.Status);
}
[Fact]
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.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);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal("mirror", stored!.Log.Backend);
Assert.Null(result.Mirror);
}
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,267 @@
using System.Buffers.Binary;
using System.Collections.Generic;
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
{
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
[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
}
},
Security = new AttestorOptions.SecurityOptions
{
SignerIdentity = new AttestorOptions.SignerIdentityOptions
{
Mode = { "kms" },
KmsKeys = { HmacSecretBase64 }
}
}
});
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, HmacSecret);
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>(),
metrics);
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid,
Bundle = submission.Bundle
});
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
}
},
Security = new AttestorOptions.SecurityOptions
{
SignerIdentity = new AttestorOptions.SignerIdentityOptions
{
Mode = { "kms" },
KmsKeys = { HmacSecretBase64 }
}
}
});
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, HmacSecret);
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>(),
metrics);
var tamperedBundle = CloneBundle(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("signature_invalid", StringComparison.OrdinalIgnoreCase));
}
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
{
var payload = Encoding.UTF8.GetBytes("{}");
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "kms",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(payload)
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
using (var hmac = new HMACSHA256(hmacSecret))
{
var signature = hmac.ComputeHash(preAuth);
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = "kms-test",
Signature = Convert.ToBase64String(signature)
});
}
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
{
var clone = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = source.Mode,
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = source.Dsse.PayloadType,
PayloadBase64 = source.Dsse.PayloadBase64
}
};
foreach (var certificate in source.CertificateChain)
{
clone.CertificateChain.Add(certificate);
}
foreach (var signature in source.Dsse.Signatures)
{
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = signature.KeyId,
Signature = signature.Signature
});
}
return clone;
}
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
{
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
var offset = 0;
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
offset += 6;
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
offset += 8;
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
offset += headerBytes.Length;
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
offset += 8;
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
return buffer;
}
}

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,405 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting;
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;
using Serilog.Context;
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);
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
options.AddPolicy("attestor-submissions", httpContext =>
{
var identity = httpContext.Connection.ClientCertificate?.Thumbprint
?? httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("client_id")?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
var quota = attestorOptions.Quotas.PerCaller;
var tokensPerPeriod = Math.Max(1, quota.Qps);
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
});
});
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;
}
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
https.ClientCertificateValidation = (certificate, _, _) =>
{
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
{
return true;
}
if (certificate is null)
{
Log.Warning("Client certificate missing");
return false;
}
if (clientCertificateAuthorities.Count > 0)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
TrustMode = X509ChainTrustMode.CustomRootTrust
}
};
foreach (var authority in clientCertificateAuthorities)
{
chain.ChainPolicy.CustomTrustStore.Add(authority);
}
if (!chain.Build(certificate))
{
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
return false;
}
}
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
return false;
}
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
return false;
}
return true;
};
});
});
var app = builder.Build();
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
}
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next().ConfigureAwait(false);
}
});
app.UseExceptionHandler(static handler =>
{
handler.Run(async context =>
{
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await result.ExecuteAsync(context);
});
});
app.UseRateLimiter();
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")
.RequireRateLimiting("attestor-submissions");
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,
backend = entry.Log.Backend,
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,
mirror = entry.Mirror is null ? null : new
{
backend = entry.Mirror.Backend,
uuid = entry.Mirror.Uuid,
index = entry.Mirror.Index,
logURL = entry.Mirror.Url,
status = entry.Mirror.Status,
proof = entry.Mirror.Proof is null ? null : new
{
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
{
origin = entry.Mirror.Proof.Checkpoint.Origin,
size = entry.Mirror.Proof.Checkpoint.Size,
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
},
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
{
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
path = entry.Mirror.Proof.Inclusion.Path
}
},
error = entry.Mirror.Error
},
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
};
}
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
{
var certificates = new List<X509Certificate2>();
if (string.IsNullOrWhiteSpace(path))
{
return certificates;
}
try
{
if (!File.Exists(path))
{
Log.Warning("Client CA bundle '{Path}' not found", path);
return certificates;
}
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(path);
certificates.AddRange(collection.Cast<X509Certificate2>());
}
catch (Exception ex) when (ex is IOException or CryptographicException)
{
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
}
return certificates;
}

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,10 @@
# 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-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 tracked as DOING per Wave 0A kickoff.
> Remark (2025-10-19): Dual-log submissions, signature/proof verification, and observability hardening landed; attestor endpoints now rate-limited per client with correlation-ID logging and updated docs/tests.

View File

@@ -0,0 +1,50 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of attempting to consume a DPoP nonce.
/// </summary>
public sealed class DpopNonceConsumeResult
{
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
{
Status = status;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
/// <summary>
/// Consumption status.
/// </summary>
public DpopNonceConsumeStatus Status { get; }
/// <summary>
/// Timestamp the nonce was originally issued (when available).
/// </summary>
public DateTimeOffset? IssuedAt { get; }
/// <summary>
/// Expiry timestamp for the nonce (when available).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
public static DpopNonceConsumeResult NotFound()
=> new(DpopNonceConsumeStatus.NotFound, null, null);
}
/// <summary>
/// Known statuses for nonce consumption attempts.
/// </summary>
public enum DpopNonceConsumeStatus
{
Success,
Expired,
NotFound
}

View File

@@ -0,0 +1,56 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the result of issuing a DPoP nonce.
/// </summary>
public sealed class DpopNonceIssueResult
{
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
{
Status = status;
Nonce = nonce;
ExpiresAt = expiresAt;
Error = error;
}
/// <summary>
/// Issue status.
/// </summary>
public DpopNonceIssueStatus Status { get; }
/// <summary>
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
/// </summary>
public string? Nonce { get; }
/// <summary>
/// Expiry timestamp for the issued nonce (UTC).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Additional failure information, where applicable.
/// </summary>
public string? Error { get; }
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
public static DpopNonceIssueResult RateLimited(string? error = null)
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
public static DpopNonceIssueResult Failure(string? error = null)
=> new(DpopNonceIssueStatus.Failure, null, null, error);
}
/// <summary>
/// Known statuses for nonce issuance.
/// </summary>
public enum DpopNonceIssueStatus
{
Success,
RateLimited,
Failure
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
internal static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}
internal static byte[] ComputeNonceHash(string nonce)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
var bytes = Encoding.UTF8.GetBytes(nonce);
return SHA256.HashData(bytes);
}
internal static string EncodeHash(ReadOnlySpan<byte> hash)
=> Convert.ToHexString(hash);
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
return string.Create(
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
static (span, parts) =>
{
var index = 0;
const string Prefix = "dpop-nonce:";
Prefix.CopyTo(span);
index += Prefix.Length;
index = Append(span, index, parts.Item1);
span[index++] = ':';
index = Append(span, index, parts.Item2);
span[index++] = ':';
_ = Append(span, index, parts.Item3);
});
static int Append(Span<char> span, int index, string value)
{
if (value.Length == 0)
{
throw new ArgumentException("Value must not be empty after trimming.");
}
value.AsSpan().CopyTo(span[index..]);
return index + value.Length;
}
}
}

View File

@@ -0,0 +1,258 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
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";
private readonly DpopValidationOptions options;
private readonly IDpopReplayCache replayCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<DpopProofValidator>? logger;
private readonly JwtSecurityTokenHandler tokenHandler = new();
public DpopProofValidator(
IOptions<DpopValidationOptions> options,
IDpopReplayCache? replayCache = null,
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
this.options = cloned;
this.replayCache = replayCache ?? NullReplayCache.Instance;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = timeProvider.GetUtcNow();
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
}
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
}
if (!headerElement.TryGetProperty("alg", out var algElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
{
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
}
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
}
if (!payloadElement.TryGetProperty("htm", out var htmElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
}
if (!payloadElement.TryGetProperty("htu", out var htuElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
if (issuedAt - options.AllowedClockSkew > now)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
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)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
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()!;
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwk,
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
};
tokenHandler.ValidateToken(proof, parameters, out _);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
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)
{
element = default;
error = null;
var segments = token.Split('.');
if (segments.Length != 3)
{
error = "Token must contain three segments.";
return false;
}
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index out of range.";
return false;
}
try
{
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static class NullReplayCache
{
public static readonly IDpopReplayCache Instance = new Noop();
private sealed class Noop : IDpopReplayCache
{
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -0,0 +1,77 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
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);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
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()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.IdentityModel.Tokens;
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, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
ErrorDescription = errorDescription;
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
public string? ErrorCode { get; }
public string? ErrorDescription { get; }
public SecurityKey? PublicKey { get; }
public string? JwtId { get; }
public DateTimeOffset? IssuedAt { get; }
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, null);
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Provides persistence and validation for DPoP nonces.
/// </summary>
public interface IDpopNonceStore
{
/// <summary>
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
/// </summary>
/// <param name="audience">Audience the nonce applies to.</param>
/// <param name="clientId">Client identifier requesting the nonce.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="ttl">Time-to-live for the nonce.</param>
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing the issued nonce.</returns>
ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to consume a nonce previously issued for the tuple.
/// </summary>
/// <param name="nonce">Nonce supplied by the client.</param>
/// <param name="audience">Audience the nonce should match.</param>
/// <param name="clientId">Client identifier.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing whether the nonce was accepted.</returns>
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
/// </summary>
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ILogger<InMemoryDpopNonceStore>? logger;
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
bool allowed;
lock (bucket.SyncRoot)
{
bucket.Prune(now - IssuanceWindow);
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
{
allowed = false;
}
else
{
bucket.IssuanceTimes.Enqueue(now);
allowed = true;
}
}
if (!allowed)
{
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
}
var nonce = GenerateNonce();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
var expiresAt = now + ttl;
nonces[nonceKey] = new StoredNonce(now, expiresAt);
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
}
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
if (!nonces.TryRemove(nonceKey, out var stored))
{
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
}
if (stored.ExpiresAt <= now)
{
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
}
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
}
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
{
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var digest = ComputeSha256(nonce);
return $"{bucketKey}::{digest}";
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Base64UrlEncode(buffer);
}
private sealed class StoredNonce
{
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
internal DateTimeOffset IssuedAt { get; }
internal DateTimeOffset ExpiresAt { get; }
}
private sealed class IssuanceBucket
{
internal object SyncRoot { get; } = new();
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
internal void Prune(DateTimeOffset threshold)
{
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
{
IssuanceTimes.Dequeue();
}
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
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);
private readonly TimeProvider timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
continue;
}
if (existing > now)
{
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
}
return ValueTask.FromResult(false);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
}
}
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
if value ~= false and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
{
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var issuedAt = timeProvider.GetUtcNow();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var rateKey = (RedisKey)(baseKey + ":rate");
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
if (rateCount == 1)
{
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
}
if (rateCount > maxIssuancePerMinute)
{
return DpopNonceIssueResult.RateLimited("rate_limited");
}
var nonce = DpopNonceUtilities.GenerateNonce();
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var expiresAt = issuedAt + ttl;
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var rawResult = await database.ScriptEvaluateAsync(
ConsumeScript,
new[] { nonceKey },
new RedisValue[] { hash }).ConfigureAwait(false);
if (rawResult.IsNull || (long)rawResult != 1)
{
return DpopNonceConsumeResult.NotFound();
}
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
if (!metadata.IsNull)
{
var parts = metadata.ToString()
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
{
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
return expiresAt <= timeProvider.GetUtcNow()
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
}
}
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
}
}

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,38 @@
<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="StackExchange.Redis" Version="2.8.24" />
<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,74 +1,74 @@
using System;
using System.Linq;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
.WithScopes(new[] { "Feedser.Jobs.Trigger", " feedser.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
.WithAudiences(new[] { " api://feedser ", "api://cli", "api://feedser" });
Assert.Equal(
new[] { "authority.users.manage", "feedser.jobs.trigger" },
builder.NormalizedScopes);
Assert.Equal(
new[] { "api://cli", "api://feedser" },
builder.Audiences);
}
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
.WithTenant(" default ")
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Feedser.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
.WithAudience(" api://feedser ")
.WithIssuedAt(now)
.WithExpires(now.AddMinutes(5))
.AddClaim(" custom ", " value ");
var principal = builder.Build();
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal("custom", identity.AuthenticationType);
Assert.Equal("Jane Doe", identity.Name);
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, scopeClaims);
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
Assert.Equal("authority.users.manage feedser.jobs.trigger", scopeList);
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "api://feedser" }, audienceClaims);
var issuedAt = principal.FindFirstValue("iat");
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
var expires = principal.FindFirstValue("exp");
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
}
}
using System;
using System.Linq;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
.WithScopes(new[] { "Concelier.Jobs.Trigger", " concelier.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
.WithAudiences(new[] { " api://concelier ", "api://cli", "api://concelier" });
Assert.Equal(
new[] { "authority.users.manage", "concelier.jobs.trigger" },
builder.NormalizedScopes);
Assert.Equal(
new[] { "api://cli", "api://concelier" },
builder.Audiences);
}
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
.WithTenant(" default ")
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Concelier.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
.WithAudience(" api://concelier ")
.WithIssuedAt(now)
.WithExpires(now.AddMinutes(5))
.AddClaim(" custom ", " value ");
var principal = builder.Build();
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal("custom", identity.AuthenticationType);
Assert.Equal("Jane Doe", identity.Name);
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, scopeClaims);
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
Assert.Equal("authority.users.manage concelier.jobs.trigger", scopeList);
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "api://concelier" }, audienceClaims);
var issuedAt = principal.FindFirstValue("iat");
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
var expires = principal.FindFirstValue("exp");
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
}
}

View File

@@ -1,53 +1,53 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
Assert.Equal("Authentication required", details.Title);
Assert.Equal("/jobs", details.Instance);
Assert.Equal("unauthorized", details.Extensions["error"]);
Assert.Equal(details.Detail, details.Extensions["error_description"]);
}
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal("expired refresh token", details.Detail);
Assert.Equal("invalid_token", details.Extensions["error"]);
}
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
new[] { StellaOpsScopes.FeedserJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.FeedserJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
Assert.Equal("Authentication required", details.Title);
Assert.Equal("/jobs", details.Instance);
Assert.Equal("unauthorized", details.Extensions["error"]);
Assert.Equal(details.Detail, details.Extensions["error_description"]);
}
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal("expired refresh token", details.Detail);
Assert.Equal("invalid_token", details.Extensions["error"]);
}
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
new[] { StellaOpsScopes.ConcelierJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}

View File

@@ -1,79 +1,79 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Feedser jobs.
/// </summary>
public const string FeedserJobsTrigger = "feedser.jobs.trigger";
/// <summary>
/// Scope required to manage Feedser merge operations.
/// </summary>
public const string FeedserMerge = "feedser.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
FeedserJobsTrigger,
FeedserMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}

View File

@@ -1,84 +1,84 @@
using System;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsAuthClientOptionsTests
{
[Fact]
public void Validate_NormalizesScopes()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli",
HttpTimeout = TimeSpan.FromSeconds(15)
};
options.DefaultScopes.Add(" Feedser.Jobs.Trigger ");
options.DefaultScopes.Add("feedser.jobs.trigger");
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
options.Validate();
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsAuthClientOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_NormalizesRetryDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test"
};
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.Zero);
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
options.Validate();
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
}
[Fact]
public void Validate_DisabledRetries_ProducesEmptyDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
EnableRetries = false
};
options.Validate();
Assert.Empty(options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_OfflineToleranceNegative()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsAuthClientOptionsTests
{
[Fact]
public void Validate_NormalizesScopes()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli",
HttpTimeout = TimeSpan.FromSeconds(15)
};
options.DefaultScopes.Add(" Concelier.Jobs.Trigger ");
options.DefaultScopes.Add("concelier.jobs.trigger");
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
options.Validate();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsAuthClientOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_NormalizesRetryDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test"
};
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.Zero);
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
options.Validate();
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
}
[Fact]
public void Validate_DisabledRetries_ProducesEmptyDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
EnableRetries = false
};
options.Validate();
Assert.Empty(options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_OfflineToleranceNegative()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,111 +1,111 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsTokenClientTests
{
[Fact]
public async Task RequestPasswordToken_ReturnsResultAndCaches()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
var responses = new Queue<HttpResponseMessage>();
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"feedser.jobs.trigger\"}"));
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
return Task.FromResult(responses.Dequeue());
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli"
};
options.DefaultScopes.Add("feedser.jobs.trigger");
options.Validate();
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
var result = await client.RequestPasswordTokenAsync("user", "pass");
Assert.Equal("abc", result.AccessToken);
Assert.Contains("feedser.jobs.trigger", result.Scopes);
await client.CacheTokenAsync("key", result.ToCacheEntry());
var cached = await client.GetCachedTokenAsync("key");
Assert.NotNull(cached);
Assert.Equal("abc", cached!.AccessToken);
var jwks = await client.GetJsonWebKeySetAsync();
Assert.Empty(jwks.Keys);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions value;
public TestOptionsMonitor(TOptions value)
{
this.value = value;
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsTokenClientTests
{
[Fact]
public async Task RequestPasswordToken_ReturnsResultAndCaches()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
var responses = new Queue<HttpResponseMessage>();
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"concelier.jobs.trigger\"}"));
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
return Task.FromResult(responses.Dequeue());
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli"
};
options.DefaultScopes.Add("concelier.jobs.trigger");
options.Validate();
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
var result = await client.RequestPasswordTokenAsync("user", "pass");
Assert.Equal("abc", result.AccessToken);
Assert.Contains("concelier.jobs.trigger", result.Scopes);
await client.CacheTokenAsync("key", result.ToCacheEntry());
var cached = await client.GetCachedTokenAsync("key");
Assert.NotNull(cached);
Assert.Equal("abc", cached!.AccessToken);
var jwks = await client.GetJsonWebKeySetAsync();
Assert.Empty(jwks.Keys);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions value;
public TestOptionsMonitor(TOptions value)
{
this.value = value;
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,44 +1,44 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.example",
["Authority:ResourceServer:Audiences:0"] = "api://feedser",
["Authority:ResourceServer:RequiredScopes:0"] = "feedser.jobs.trigger",
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsResourceServerAuthentication(configuration);
using var provider = services.BuildServiceProvider();
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(jwtOptions.Authority);
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://feedser", jwtOptions.TokenValidationParameters.ValidAudiences);
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "feedser.jobs.trigger" }, resourceOptions.NormalizedScopes);
}
}
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.example",
["Authority:ResourceServer:Audiences:0"] = "api://concelier",
["Authority:ResourceServer:RequiredScopes:0"] = "concelier.jobs.trigger",
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsResourceServerAuthentication(configuration);
using var provider = services.BuildServiceProvider();
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(jwtOptions.Authority);
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
}
}

View File

@@ -1,50 +1,50 @@
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://feedser ");
options.Audiences.Add("api://feedser");
options.Audiences.Add("api://feedser-admin");
options.RequiredScopes.Add(" Feedser.Jobs.Trigger ");
options.RequiredScopes.Add("feedser.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://feedser", "api://feedser-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://concelier ");
options.Audiences.Add("api://concelier");
options.Audiences.Add("api://concelier-admin");
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
options.RequiredScopes.Add("concelier.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,123 +1,123 @@
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithScopes(new[] { StellaOpsScopes.FeedserJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,9 +1,9 @@
# StellaOps.Auth.ServerIntegration
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
- Network bypass mask evaluation for on-host automation.
- Consistent `ProblemDetails` responses and policy helpers shared with Feedser/Backend services.
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.
# StellaOps.Auth.ServerIntegration
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
- Network bypass mask evaluation for on-host automation.
- Consistent `ProblemDetails` responses and policy helpers shared with Concelier/Backend services.
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.

View File

@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
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;
@@ -42,23 +45,91 @@ public class StandardClientProvisioningStoreTests
Assert.Contains("scopeA", descriptor.AllowedScopes);
}
[Fact]
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "signer",
confidential: false,
displayName: "Signer",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
var document = Assert.Contains("signer", store.Documents);
Assert.Equal("attestor signer", document.Value.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.Audiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff",
subject: "CN=mtls-client",
issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary");
var registration = new AuthorityClientRegistration(
clientId: "mtls-client",
confidential: true,
displayName: "MTLS Client",
clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
var document = Assert.Contains("mtls-client", store.Documents).Value;
var binding = Assert.Single(document.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
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 +140,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

@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<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

@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<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="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,150 +1,235 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
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);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
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

@@ -5,10 +5,10 @@
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⏳ Awaiting AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 completion to unlock Wave0B verification paths. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⏳ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⏳ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
@@ -16,3 +16,5 @@
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
> Check-in (2025-10-19): Wave0A dependencies (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open, so SEC2/SEC3/SEC5 remain in progress without new scope until upstream limiter updates land.

View File

@@ -1,12 +1,14 @@
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known metadata keys persisted with Authority client registrations.
/// </summary>
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
}
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known metadata keys persisted with Authority client registrations.
/// </summary>
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
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,61 +1,69 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth client/application registered with Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("clientType")]
public string ClientType { get; set; } = "confidential";
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("permissions")]
public List<string> Permissions { get; set; } = new();
[BsonElement("requirements")]
public List<string> Requirements { get; set; } = new();
[BsonElement("redirectUris")]
public List<string> RedirectUris { get; set; } = new();
[BsonElement("postLogoutRedirectUris")]
public List<string> PostLogoutRedirectUris { get; set; } = new();
[BsonElement("properties")]
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("disabled")]
public bool Disabled { get; set; }
}
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth client/application registered with Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("clientType")]
public string ClientType { get; set; } = "confidential";
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("permissions")]
public List<string> Permissions { get; set; } = new();
[BsonElement("requirements")]
public List<string> Requirements { get; set; } = new();
[BsonElement("redirectUris")]
public List<string> RedirectUris { get; set; } = new();
[BsonElement("postLogoutRedirectUris")]
public List<string> PostLogoutRedirectUris { get; set; } = new();
[BsonElement("properties")]
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("plugin")]
[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;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("disabled")]
public bool Disabled { get; set; }
}

View File

@@ -62,6 +62,18 @@ public sealed class AuthorityTokenDocument
[BsonIgnoreIfNull]
public string? RevokedReasonDescription { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("senderKeyThumbprint")]
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]

View File

@@ -1,126 +1,129 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Migrations;
using StellaOps.Authority.Storage.Mongo.Options;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
/// <summary>
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
/// </summary>
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.AddOptions<AuthorityMongoOptions>()
.Configure(configureOptions)
.PostConfigure(static options => options.EnsureValid());
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IMongoClient>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
return new MongoClient(options.ConnectionString);
});
services.AddSingleton<IMongoDatabase>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
var client = sp.GetRequiredService<IMongoClient>();
var settings = new MongoDatabaseSettings
{
ReadConcern = ReadConcern.Majority,
WriteConcern = WriteConcern.WMajority,
ReadPreference = ReadPreference.PrimaryPreferred
};
var database = client.GetDatabase(options.GetDatabaseName(), settings);
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
return database.WithWriteConcern(writeConcern);
});
services.AddSingleton<AuthorityMongoInitializer>();
services.AddSingleton<AuthorityMongoMigrationRunner>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
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;
/// <summary>
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
/// </summary>
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.AddOptions<AuthorityMongoOptions>()
.Configure(configureOptions)
.PostConfigure(static options => options.EnsureValid());
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IMongoClient>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
return new MongoClient(options.ConnectionString);
});
services.AddSingleton<IMongoDatabase>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
var client = sp.GetRequiredService<IMongoClient>();
var settings = new MongoDatabaseSettings
{
ReadConcern = ReadConcern.Majority,
WriteConcern = WriteConcern.WMajority,
ReadPreference = ReadPreference.PrimaryPreferred
};
var database = client.GetDatabase(options.GetDatabaseName(), settings);
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
return database.WithWriteConcern(writeConcern);
});
services.AddSingleton<AuthorityMongoInitializer>();
services.AddSingleton<AuthorityMongoMigrationRunner>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
return services;
}
}

View File

@@ -1,24 +1,30 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
var indexModels = new[]
{
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
new CreateIndexOptions { Name = "client_disabled" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
var indexModels = new[]
{
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.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

@@ -1,45 +1,51 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
{
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys
.Ascending(t => t.Status)
.Ascending(t => t.RevokedAt),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
};
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
new CreateIndexOptions<AuthorityTokenDocument>
{
Name = "token_expiry_ttl",
ExpireAfter = TimeSpan.Zero,
PartialFilterExpression = expirationFilter
}));
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
{
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys
.Ascending(t => t.Status)
.Ascending(t => t.RevokedAt),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderConstraint),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_constraint", Sparse = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderKeyThumbprint),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
};
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
new CreateIndexOptions<AuthorityTokenDocument>
{
Name = "token_expiry_ttl",
ExpireAfter = TimeSpan.Zero,
PartialFilterExpression = expirationFilter
}));
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

@@ -1,18 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.22.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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
</Project>

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,64 +1,88 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityClientStore : IAuthorityClientStore
{
private readonly IMongoCollection<AuthorityClientDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityClientStore> logger;
public AuthorityClientStore(
IMongoCollection<AuthorityClientDocument> collection,
TimeProvider clock,
ILogger<AuthorityClientStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return null;
}
var id = clientId.Trim();
return await collection.Find(c => c.ClientId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
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);
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
}
}
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return false;
}
var id = clientId.Trim();
var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false);
return result.DeletedCount > 0;
}
}
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;
internal sealed class AuthorityClientStore : IAuthorityClientStore
{
private readonly IMongoCollection<AuthorityClientDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityClientStore> logger;
public AuthorityClientStore(
IMongoCollection<AuthorityClientDocument> collection,
TimeProvider clock,
ILogger<AuthorityClientStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return null;
}
var id = clientId.Trim();
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, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
var options = new ReplaceOptions { IsUpsert = true };
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 client {ClientId}.", document.ClientId);
}
}
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return false;
}
var id = clientId.Trim();
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,52 +1,72 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
private readonly ILogger<AuthorityLoginAttemptStore> logger;
public AuthorityLoginAttemptStore(
IMongoCollection<AuthorityLoginAttemptDocument> collection,
ILogger<AuthorityLoginAttemptStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
document.EventType,
document.SubjectId ?? document.Username ?? "<unknown>",
document.Outcome);
}
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
{
return Array.Empty<AuthorityLoginAttemptDocument>();
}
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);
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
}
}
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;
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
private readonly ILogger<AuthorityLoginAttemptStore> logger;
public AuthorityLoginAttemptStore(
IMongoCollection<AuthorityLoginAttemptDocument> collection,
ILogger<AuthorityLoginAttemptStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
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,
document.SubjectId ?? document.Username ?? "<unknown>",
document.Outcome);
}
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
{
return Array.Empty<AuthorityLoginAttemptDocument>();
}
var normalized = subjectId.Trim();
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

@@ -1,83 +1,97 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
{
private const string StateId = "state";
private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
private readonly ILogger<AuthorityRevocationExportStateStore> logger;
public AuthorityRevocationExportStateStore(
IMongoCollection<AuthorityRevocationExportStateDocument> collection,
ILogger<AuthorityRevocationExportStateStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
{
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
long expectedSequence,
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken)
{
if (newSequence <= 0)
{
throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
}
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
if (expectedSequence > 0)
{
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
}
else
{
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
}
var update = Builders<AuthorityRevocationExportStateDocument>.Update
.Set(d => d.Sequence, newSequence)
.Set(d => d.LastBundleId, bundleId)
.Set(d => d.LastIssuedAt, issuedAt);
var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
{
IsUpsert = expectedSequence == 0,
ReturnDocument = ReturnDocument.After
};
try
{
var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("Revocation export state update conflict.");
}
logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
return result;
}
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
{
private const string StateId = "state";
private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
private readonly ILogger<AuthorityRevocationExportStateStore> logger;
public AuthorityRevocationExportStateStore(
IMongoCollection<AuthorityRevocationExportStateDocument> collection,
ILogger<AuthorityRevocationExportStateStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
long expectedSequence,
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (newSequence <= 0)
{
throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
}
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
if (expectedSequence > 0)
{
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
}
else
{
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
}
var update = Builders<AuthorityRevocationExportStateDocument>.Update
.Set(d => d.Sequence, newSequence)
.Set(d => d.LastBundleId, bundleId)
.Set(d => d.LastIssuedAt, issuedAt);
var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
{
IsUpsert = expectedSequence == 0,
ReturnDocument = ReturnDocument.After
};
try
{
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.");
}
logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
return result;
}
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
}
}
}

View File

@@ -1,143 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
{
private readonly IMongoCollection<AuthorityRevocationDocument> collection;
private readonly ILogger<AuthorityRevocationStore> logger;
public AuthorityRevocationStore(
IMongoCollection<AuthorityRevocationDocument> collection,
ILogger<AuthorityRevocationStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
if (string.IsNullOrWhiteSpace(document.Category))
{
throw new ArgumentException("Revocation category is required.", nameof(document));
}
if (string.IsNullOrWhiteSpace(document.RevocationId))
{
throw new ArgumentException("Revocation identifier is required.", nameof(document));
}
document.Category = document.Category.Trim();
document.RevocationId = document.RevocationId.Trim();
document.Scopes = NormalizeScopes(document.Scopes);
document.Metadata = NormalizeMetadata(document.Metadata);
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
var now = DateTimeOffset.UtcNow;
document.UpdatedAt = now;
var existing = await collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
document.CreatedAt = now;
}
else
{
document.Id = existing.Id;
document.CreatedAt = existing.CreatedAt;
}
await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, 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)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
{
return false;
}
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
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);
if (result.DeletedCount > 0)
{
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
return true;
}
return false;
}
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
{
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)
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents;
}
private static List<string>? NormalizeScopes(List<string>? scopes)
{
if (scopes is null || scopes.Count == 0)
{
return null;
}
var distinct = scopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Select(scope => scope.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(scope => scope, StringComparer.Ordinal)
.ToList();
return distinct.Count == 0 ? null : distinct;
}
private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
result[pair.Key.Trim()] = pair.Value;
}
return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
{
private readonly IMongoCollection<AuthorityRevocationDocument> collection;
private readonly ILogger<AuthorityRevocationStore> logger;
public AuthorityRevocationStore(
IMongoCollection<AuthorityRevocationDocument> collection,
ILogger<AuthorityRevocationStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
if (string.IsNullOrWhiteSpace(document.Category))
{
throw new ArgumentException("Revocation category is required.", nameof(document));
}
if (string.IsNullOrWhiteSpace(document.RevocationId))
{
throw new ArgumentException("Revocation identifier is required.", nameof(document));
}
document.Category = document.Category.Trim();
document.RevocationId = document.RevocationId.Trim();
document.Scopes = NormalizeScopes(document.Scopes);
document.Metadata = NormalizeMetadata(document.Metadata);
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
var now = DateTimeOffset.UtcNow;
document.UpdatedAt = now;
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (existing is null)
{
document.CreatedAt = now;
}
else
{
document.Id = existing.Id;
document.CreatedAt = existing.CreatedAt;
}
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, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
{
return false;
}
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
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);
return true;
}
return false;
}
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 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);
return documents;
}
private static List<string>? NormalizeScopes(List<string>? scopes)
{
if (scopes is null || scopes.Count == 0)
{
return null;
}
var distinct = scopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Select(scope => scope.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(scope => scope, StringComparer.Ordinal)
.ToList();
return distinct.Count == 0 ? null : distinct;
}
private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
result[pair.Key.Trim()] = pair.Value;
}
return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -1,69 +1,104 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
{
private readonly IMongoCollection<AuthorityScopeDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityScopeStore> logger;
public AuthorityScopeStore(
IMongoCollection<AuthorityScopeDocument> collection,
TimeProvider clock,
ILogger<AuthorityScopeStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var normalized = name.Trim();
return await collection.Find(s => s.Name == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken)
{
var 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)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
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);
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
}
}
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.Trim();
var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false);
return result.DeletedCount > 0;
}
}
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;
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
{
private readonly IMongoCollection<AuthorityScopeDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityScopeStore> logger;
public AuthorityScopeStore(
IMongoCollection<AuthorityScopeDocument> collection,
TimeProvider clock,
ILogger<AuthorityScopeStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var normalized = name.Trim();
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, IClientSessionHandle? session = null)
{
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, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
var options = new ReplaceOptions { IsUpsert = true };
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, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.Trim();
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,81 +1,105 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityUserStore : IAuthorityUserStore
{
private readonly IMongoCollection<AuthorityUserDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityUserStore> logger;
public AuthorityUserStore(
IMongoCollection<AuthorityUserDocument> collection,
TimeProvider clock,
ILogger<AuthorityUserStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
return await collection
.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
return null;
}
var normalised = normalizedUsername.Trim();
return await collection
.Find(u => u.NormalizedUsername == normalised)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
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);
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
}
}
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return false;
}
var normalised = subjectId.Trim();
var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false);
return result.DeletedCount > 0;
}
}
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;
internal sealed class AuthorityUserStore : IAuthorityUserStore
{
private readonly IMongoCollection<AuthorityUserDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityUserStore> logger;
public AuthorityUserStore(
IMongoCollection<AuthorityUserDocument> collection,
TimeProvider clock,
ILogger<AuthorityUserStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
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, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
return null;
}
var normalised = normalizedUsername.Trim();
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, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
document.UpdatedAt = clock.GetUtcNow();
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
var options = new ReplaceOptions { IsUpsert = true };
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 user {SubjectId}.", document.SubjectId);
}
}
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return false;
}
var normalised = subjectId.Trim();
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;
}
}

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