Resolve Concelier/Excititor merge conflicts
This commit is contained in:
		| @@ -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. | ||||
|  | ||||
| ``` | ||||
| ``` | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
							
								
								
									
										21
									
								
								src/StellaOps.Attestor/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/StellaOps.Attestor/AGENTS.md
									
									
									
									
									
										Normal 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. | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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>(); | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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>(); | ||||
| } | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| namespace StellaOps.Attestor.Core.Submission; | ||||
|  | ||||
| public sealed class AttestorSubmissionValidationResult | ||||
| { | ||||
|     public AttestorSubmissionValidationResult(byte[] canonicalBundle) | ||||
|     { | ||||
|         CanonicalBundle = canonicalBundle; | ||||
|     } | ||||
|  | ||||
|     public byte[] CanonicalBundle { get; } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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>(); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| using System.Runtime.CompilerServices; | ||||
|  | ||||
| [assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")] | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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>() | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										405
									
								
								src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										405
									
								
								src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
| @@ -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> | ||||
							
								
								
									
										118
									
								
								src/StellaOps.Attestor/StellaOps.Attestor.sln
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/StellaOps.Attestor/StellaOps.Attestor.sln
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										10
									
								
								src/StellaOps.Attestor/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/StellaOps.Attestor/TASKS.md
									
									
									
									
									
										Normal 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. | ||||
							
								
								
									
										50
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										56
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										66
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
									
									
									
									
									
										Normal 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										258
									
								
								src/StellaOps.Auth.Security/Dpop/DpopProofValidator.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								src/StellaOps.Auth.Security/Dpop/DpopProofValidator.cs
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										77
									
								
								src/StellaOps.Auth.Security/Dpop/DpopValidationOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/StellaOps.Auth.Security/Dpop/DpopValidationOptions.cs
									
									
									
									
									
										Normal 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."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										40
									
								
								src/StellaOps.Auth.Security/Dpop/DpopValidationResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/StellaOps.Auth.Security/Dpop/DpopValidationResult.cs
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
							
								
								
									
										45
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
							
								
								
									
										6
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopProofValidator.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopProofValidator.cs
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
							
								
								
									
										6
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopReplayCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/StellaOps.Auth.Security/Dpop/IDpopReplayCache.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| namespace StellaOps.Auth.Security.Dpop; | ||||
|  | ||||
| public interface IDpopReplayCache | ||||
| { | ||||
|     ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										176
									
								
								src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
									
									
									
									
									
										Normal 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(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										66
									
								
								src/StellaOps.Auth.Security/Dpop/InMemoryDpopReplayCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/StellaOps.Auth.Security/Dpop/InMemoryDpopReplayCache.cs
									
									
									
									
									
										Normal 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 _); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										138
									
								
								src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
									
									
									
									
									
										Normal 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()); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								src/StellaOps.Auth.Security/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/StellaOps.Auth.Security/README.md
									
									
									
									
									
										Normal 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. | ||||
							
								
								
									
										38
									
								
								src/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
									
									
									
									
									
										Normal 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> | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|             { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|             { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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>()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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)); | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,10 +5,10 @@ | ||||
| | PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | 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 Wave 0B 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 | PLG1–PLG3 | 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): Wave 0A 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. | ||||
|   | ||||
| @@ -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"; | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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; | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
|   | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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
		Reference in New Issue
	
	Block a user