From f72c5c513ab83428d371d6d0704761fcb8342c47 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 3 Nov 2025 19:12:51 +0200 Subject: [PATCH] up --- docs/implplan/SPRINT_100_identity_signing.md | 1 + docs/replay/DETERMINISTIC_REPLAY.md | 423 ++++++++++++++++++ docs/replay/DEVS_GUIDE_REPLAY.md | 113 +++++ .../ServiceAccountAdminEndpointsTests.cs | 85 ++-- .../Console/ConsoleEndpointsTests.cs | 33 +- .../AuthorityWebApplicationFactory.cs | 27 +- .../EnvironmentVariableScope.cs | 44 ++ .../Infrastructure/TestAuthHandler.cs | 5 +- .../NotifyAckTokenRotationEndpointTests.cs | 28 +- .../OpenApi/OpenApiDiscoveryEndpointTests.cs | 12 +- .../ClientCredentialsAndTokenHandlersTests.cs | 136 +++--- .../OpenIddict/DiscoveryMetadataTests.cs | 4 +- .../OpenIddict/LegacyAuthDeprecationTests.cs | 4 +- .../OpenIddict/PasswordGrantHandlersTests.cs | 44 +- .../AuthorityRateLimiterIntegrationTests.cs | 105 +++-- .../VulnWorkflowTokenEndpointTests.cs | 64 ++- src/Authority/StellaOps.Authority/TASKS.md | 1 + 17 files changed, 931 insertions(+), 198 deletions(-) create mode 100644 docs/replay/DETERMINISTIC_REPLAY.md create mode 100644 docs/replay/DEVS_GUIDE_REPLAY.md create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs diff --git a/docs/implplan/SPRINT_100_identity_signing.md b/docs/implplan/SPRINT_100_identity_signing.md index 1ac6e7d3..60a8a2d1 100644 --- a/docs/implplan/SPRINT_100_identity_signing.md +++ b/docs/implplan/SPRINT_100_identity_signing.md @@ -86,6 +86,7 @@ AUTH-TEN-49-001 | DOING (2025-11-02) | Implement service accounts & delegation t > 2025-11-02: Service account store + configuration wired, delegation quotas enforced, token persistence extended with `serviceAccountId`/`tokenKind`/`actorChain`, docs & samples refreshed, and new tests cover delegated issuance/persistence. > 2025-11-02: Updated bootstrap test fixtures to use AuthorityDelegation seed types and verified `/internal/service-accounts` endpoints respond as expected via targeted Authority tests. > 2025-11-02: Documented bootstrap admin API usage (`/internal/service-accounts/**`) and clarified that repeated seeding preserves Mongo `_id`/`createdAt` values to avoid immutable field errors. +> 2025-11-03: Patched Authority test harness to seed enabled service-account records deterministically and restored `StellaOps.Authority.Tests` to green (covers `/internal/service-accounts` listing + revocation paths). AUTH-VULN-29-001 | DONE (2025-11-03) | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. Dependencies: AUTH-POLICY-27-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) AUTH-VULN-29-002 | DONE (2025-11-03) | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. Dependencies: AUTH-VULN-29-001, LEDGER-29-002. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) AUTH-VULN-29-003 | DOING (2025-11-03) | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. Dependencies: AUTH-VULN-29-001..002. | Authority Core & Docs Guild (src/Authority/StellaOps.Authority/TASKS.md) diff --git a/docs/replay/DETERMINISTIC_REPLAY.md b/docs/replay/DETERMINISTIC_REPLAY.md new file mode 100644 index 00000000..376b7d1b --- /dev/null +++ b/docs/replay/DETERMINISTIC_REPLAY.md @@ -0,0 +1,423 @@ +# Stella Ops — Deterministic Replay Specification + +Version: 1.0 +Status: Draft / Internal Technical Reference +Audience: Core developers, module maintainers, audit engineers. + +--- + +## 1. Purpose + +Deterministic Replay allows any completed Stella Ops scan to be **reproduced byte-for-byte** with full cryptographic validation. +It guarantees that SBOMs, Findings, and VEX evaluations can be re-executed later to: + +- prove historical compliance decisions, +- attribute changes precisely to feeds, rules, or tools, +- support dual-signing (FIPS + regional crypto), +- and anchor cryptographic evidence in offline or public ledgers. + +Replay requires that all inputs and environmental conditions are **captured, hashed, and sealed** at scan time. + +--- + +## 2. Architecture Overview + +```mermaid +graph TD +A[Scanner.WebService] --> B[Replay Manifest] +A --> C[InputBundle] +A --> D[OutputBundle] +B --> E[DSSE Envelope] +C --> F[Feedser Snapshot Export] +C --> G[Policy/Lattice Bundle] +D --> H[DSSE Outputs (SBOM, Findings, VEX)] +E --> I[MongoDB: replay_runs] +C --> J[Blob Store: Input/Output Bundles] +```` + +### Core Artifacts + +| Artifact | Description | Format | +| ------------------- | ------------------------------------------------------ | -------------------------- | +| **Replay Manifest** | Immutable JSON describing all scan inputs and outputs. | JSON (canonicalized) | +| **InputBundle** | Feeds, rules, policies, tool binaries (hashed). | `.tar.zst` | +| **OutputBundle** | SBOM, Findings, VEX, logs. | `.tar.zst` | +| **DSSE Envelope** | Signed metadata for each artifact. | JSON / JWS | +| **Merkle Map** | Layer and feed chunk trees. | JSON (embedded or sidecar) | + +--- + +## 3. Replay Manifest Schema (v1) + +### 3.1 Top-level Layout + +```jsonc +{ + "schemaVersion": "1.0", + "scan": { + "id": "uuid", + "time": "2025-10-29T13:05:33Z", + "mode": "record", + "scannerVersion": "10.1.3", + "cryptoProfile": "FIPS-140-3+GOST-R-34.10-2012" + }, + "subject": { + "ociDigest": "sha256:abcd...", + "layers": [ + { "layerDigest": "...", "merkleRoot": "...", "leafCount": 144 } + ] + }, + "inputs": { + "feeds": [ + { + "name": "nvd", + "snapshotHash": "sha256:...", + "snapshotTime": "2025-10-29T12:00:00Z", + "merkleRoot": "..." + } + ], + "rulesBundleHash": "sha256:...", + "tools": [ + { "name": "sbomer", "version": "10.1.3", "sha256": "..." }, + { "name": "scanner", "version": "10.1.3", "sha256": "..." }, + { "name": "vexer", "version": "10.1.3", "sha256": "..." } + ], + "env": { + "os": "linux", + "arch": "x64", + "locale": "en_US.UTF-8", + "tz": "UTC", + "seed": "H(scan.id||merkleRootAllLayers)", + "flags": ["offline"] + } + }, + "policy": { + "latticeHash": "sha256:...", + "mutes": [ + { "id": "MUTE-1234", "reason": "vendor ack", "approvedBy": "authority@example.com", "approvedAt": "2025-10-29T12:55Z" } + ], + "trustProfile": "sha256:..." + }, + "outputs": { + "sbomHash": "sha256:...", + "findingsHash": "sha256:...", + "vexHash": "sha256:...", + "logHash": "sha256:..." + }, + "provenance": { + "signer": "scanner.authority", + "dsseEnvelopeHash": "sha256:...", + "rekorEntry": "optional" + } +} +``` + +--- + +## 4. Deterministic Execution Rules + +### 4.1 Environment Normalization + +* **Clock:** frozen to `scan.time` unless a rule explicitly requires “now”. +* **Random seed:** derived as `H(scan.id || MerkleRootAllLayers)`. +* **Locale/TZ:** enforced per manifest; deviations cause validation error. +* **Filesystem normalization:** + + * Normalize perms to 0644/0755. + * Path separators = `/`. + * Newlines = LF. + * JSON key order = lexical. + +### 4.2 Concurrency & I/O + +* File traversal: stable lexicographic order. +* Parallel jobs: ordered reduction by subject path. +* Temporary directories: ephemeral but deterministic hash seeds. + +### 4.3 Feeds & Policies + +* All network I/O disabled; feeds must be read from snapshot bundles. +* Policies and suppressions must resolve by hash, not name. + +--- + +## 5. DSSE and Signing + +### 5.1 Envelope Structure + +```jsonc +{ + "payloadType": "application/vnd.stella.replay.manifest+json", + "payload": "", + "signatures": [ + { "keyid": "authority-root-fips", "sig": "..." }, + { "keyid": "authority-root-gost", "sig": "..." } + ] +} +``` + +### 5.2 Verification Steps + +1. Decode payload → verify canonical form. +2. Verify each signature chain against RootPack (offline trust anchors). +3. Recompute hash and compare to `dsseEnvelopeHash` in manifest. +4. Optionally verify Rekor inclusion proof. + +--- + +## 6. CLI Interface + +### 6.1 Recording a Scan + +```bash +stella scan image:tag --record ./out/ +``` + +Produces: + +``` +out/ + ├─ manifest.json + ├─ manifest.dsse.json + ├─ inputbundle.tar.zst + ├─ outputbundle.tar.zst + └─ signatures/ +``` + +### 6.2 Verifying + +```bash +stella verify manifest.json +``` + +* Checks all hashes and DSSE envelopes. +* Prints summary: + + ``` + ✅ Verified: SBOM, Findings, VEX, Tools, Feeds, Policy + ``` + +### 6.3 Replaying + +```bash +stella replay manifest.json --strict +stella replay manifest.json --what-if --vary=feeds +``` + +* `--strict`: all inputs locked; identical result expected. +* `--what-if`: varies only specified dimension(s). + +### 6.4 Diffing + +```bash +stella diff manifestA.json manifestB.json +``` + +Shows field-level differences (feed snapshot, tool, or policy hash). + +--- + +## 7. MongoDB Schema + +### 7.1 `replay_runs` + +```jsonc +{ + "_id": "uuid", + "manifestHash": "sha256:...", + "status": "verified|failed|replayed", + "createdAt": "...", + "updatedAt": "...", + "signatures": [{ "profile": "FIPS", "verified": true }], + "outputs": { + "sbom": "sha256:...", + "findings": "sha256:..." + } +} +``` + +### 7.2 `bundles` + +```jsonc +{ + "_id": "sha256:...", + "type": "input|output|rootpack", + "size": 4123123, + "location": "/var/lib/stella/bundles/.tar.zst" +} +``` + +### 7.3 `subjects` + +```jsonc +{ + "ociDigest": "sha256:abcd...", + "layers": [ + { "layerDigest": "...", "merkleRoot": "...", "leafCount": 120 } + ] +} +``` + +--- + +## 8. Layer Merkle Implementation + +### 8.1 Algorithm + +```csharp +static string ComputeMerkleRoot(string layerTarPath) +{ + const int ChunkSize = 4 * 1024 * 1024; + var hashes = new List(); + using var fs = File.OpenRead(layerTarPath); + var buffer = new byte[ChunkSize]; + int read; + using var sha = SHA256.Create(); + while ((read = fs.Read(buffer, 0, buffer.Length)) > 0) + hashes.Add(sha.ComputeHash(buffer, 0, read)); + while (hashes.Count > 1) + hashes = hashes + .Select((h, i) => (h, i)) + .GroupBy(x => x.i / 2) + .Select(g => sha.ComputeHash(g.SelectMany(x => x.h).ToArray())) + .ToList(); + return Convert.ToHexString(hashes.Single()); +} +``` + +### 8.2 Stored Values + +```json +{ + "layerDigest": "sha256:...", + "merkleRoot": "b81f...", + "leafCount": 240, + "leavesHash": "sha256:..." +} +``` + +--- + +## 9. Replay Engine Implementation Notes (.NET 10) + +### 9.1 Manifest Parsing + +Use `System.Text.Json` with deterministic ordering: + +```csharp +var options = new JsonSerializerOptions { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolverChain = { new OrderedResolver() } +}; +``` + +### 9.2 Stable Output + +Normalize SBOM/Findings/VEX JSON: + +```csharp +string Canonicalize(string json) => + JsonSerializer.Serialize( + JsonSerializer.Deserialize(json), + options); +``` + +### 9.3 Verification Flow + +```csharp +var manifest = Manifest.Load("manifest.json"); +VerifySignatures(manifest); +VerifyHashes(manifest); +if (mode == Strict) RunPipeline(manifest); +else RunPipelineWithVariation(manifest, vary); +``` + +### 9.4 Failure Modes + +| Condition | Action | +| -------------------------------- | ----------------------------- | +| Missing snapshot or bundle | Error: `InputBundleMissing` | +| Feed hash mismatch | Error: `FeedSnapshotDrift` | +| Tool binary hash mismatch | Reject replay | +| Output hash drift in strict mode | Mark as failed, emit diff log | +| Invalid signature | Reject manifest | + +--- + +## 10. Crypto Profiles and RootPack + +### 10.1 Example Profiles + +| Profile | Algorithms | Notes | +| -------------- | ------------------------------------- | ----------------------- | +| **FIPS-140-3** | ECDSA-P256 / SHA-256 / AES-GCM | Default for US/EU | +| **GOST** | GOST R 34.10-2012 / GOST R 34.11-2012 | Russia | +| **SM** | SM2 / SM3 / SM4 | China | +| **eIDAS** | RSA-PSS / SHA-256 | EU qualified signatures | + +### 10.2 Dual-Signing Example + +```bash +stella sign manifest.json --profiles=FIPS,GOST +``` + +Produces: + +``` +signatures/ + ├─ manifest.dsse.fips.json + └─ manifest.dsse.gost.json +``` + +--- + +## 11. Test Strategy + +| Test | Description | Expected Result | +| ---------------------- | ------------------------------------ | --------------------------- | +| **Golden Replay** | Repeat identical scan → same outputs | ✅ identical hashes | +| **Feed Drift Test** | Replay with updated feeds | Only `inputs.feeds` changes | +| **Tool Upgrade Test** | Replay with new scanner version | Reject or diff by `tools` | +| **Policy Change Test** | Different lattice/mutes | Diff by `policy` section | +| **Cross-Arch Test** | x64 vs arm64 | Identical outputs | +| **Corrupted Bundle** | Tamper bundle | Verification fails | + +--- + +## 12. Example Verification Output + +``` +$ stella verify manifest.json + +[✓] Manifest integrity: OK +[✓] DSSE signatures (FIPS,GOST): OK +[✓] Feeds snapshot hash: OK +[✓] Policy + mutes hash: OK +[✓] Toolchain hash: OK +[✓] SBOM/VEX outputs: OK + +Result: VERIFIED +``` + +--- + +## 13. Future Extensions + +* Support **SPDX 3.0.1** alongside CycloneDX 1.6. +* Add **per-file Merkle proofs** for local scans. +* Ledger anchoring (Rekor, distributed Proof-Market). +* Post-quantum signatures (Dilithium/Falcon). +* Replay orchestration API (`/api/replay/:id`). + +--- + +## 14. Summary + +Deterministic Replay freezes every element of a scan: + +> *image → feeds → policy → toolchain → environment → outputs → signatures.* + +By enforcing canonical input/output states and verifiable cryptographic bindings, Stella Ops achieves **regulatory-grade replayability**, **regional crypto compliance**, and **immutable provenance** across all scans. + +--- diff --git a/docs/replay/DEVS_GUIDE_REPLAY.md b/docs/replay/DEVS_GUIDE_REPLAY.md new file mode 100644 index 00000000..ee973e61 --- /dev/null +++ b/docs/replay/DEVS_GUIDE_REPLAY.md @@ -0,0 +1,113 @@ +# Stella Ops — Developer Guide: Deterministic Replay + +## Purpose +Deterministic Replay ensures any past scan can be re-executed byte-for-byte, producing identical SBOM, Findings, and VEX results, cryptographically verifiable for audits or compliance. + +Replay is the foundation for: +- **Audit proofs** (exact past state reproduction) +- **Diff analysis** (feeds, policies, tool versions) +- **Cross-region verification** (same outputs on different hosts) +- **Long-term cryptographic trust** (re-sign with new crypto profiles) + +--- + +## Core Concepts + +| Term | Description | +|------|--------------| +| **Replay Manifest** | Immutable JSON describing all inputs, tools, env, and outputs of a scan. | +| **InputBundle** | Snapshot of feeds, rules, policies, and toolchain binaries used. | +| **OutputBundle** | SBOM, Findings, VEX, and logs from a completed scan. | +| **Layer Merkle** | Per-layer hash tree for precise deduplication and drift detection. | +| **DSSE Envelope** | Digital signature wrapper for each attestation (SBOM, Findings, Manifest, etc.). | + +--- + +## What to Freeze + +| Category | Example Contents | Required in Manifest | +|-----------|------------------|----------------------| +| **Subject** | OCI image digest, per-layer Merkle roots | ✅ | +| **Outputs** | SBOM, Findings, VEX, logs (content hashes) | ✅ | +| **Toolchain** | Sbomer, Scanner, Vexer binaries + versions + SHA256 | ✅ | +| **Feeds/VEX sources** | Full or pruned snapshot with Merkle proofs | ✅ | +| **Policy Bundle** | Lattice rules, mutes, trust profiles, thresholds | ✅ | +| **Environment** | OS, arch, locale, TZ, deterministic seed, runtime flags | ✅ | +| **Crypto Profile** | Algorithm suites (FIPS, GOST, SM, eIDAS) | ✅ | + +--- + +## Replay Modes + +| Mode | Purpose | Input Variation | Expected Output | +|------|----------|-----------------|-----------------| +| **Strict Replay** | Audit proof | None | Bit-for-bit identical | +| **What-If Replay** | Change impact analysis | One dimension (feeds/tools/policy) | Deterministic diff | + +Example: +``` + +stella replay manifest.json --strict +stella replay manifest.json --what-if --vary=feeds + +``` + +--- + +## Developer Responsibilities + +| Module | Role | +|---------|------| +| **Scanner.WebService** | Capture full input set and produce Replay Manifest + DSSE sigs. | +| **Sbomer** | Generate deterministic SBOM; normalize ordering and JSON formatting. | +| **Vexer/Excititor** | Apply lattice and mutes from policy bundle; record gating logic. | +| **Feedser/Concelier** | Freeze and export feed snapshots or Merkle proofs. | +| **Authority** | Manage signer keys and crypto profiles; issue DSSE envelopes. | +| **CLI** | Provide `scan --record`, `replay`, `verify`, `diff` commands. | + +--- + +## Workflow + +1. `stella scan image:tag --record out/` + - Generates Replay Manifest, InputBundle, OutputBundle, DSSE sigs. +2. `stella verify manifest.json` + - Validates hashes, signatures, and completeness. +3. `stella replay manifest.json --strict` + - Re-executes in sealed mode; expect byte-identical results. +4. `stella replay manifest.json --what-if --vary=feeds` + - Runs with new feeds; diff is attributed to feeds only. +5. `stella diff manifestA manifestB` + - Attribute differences by hash comparison. + +--- + +## Storage + +- **Mongo collections** + - `replay_runs`: manifest + DSSE envelopes + status + - `bundles`: content-addressed (input/output/rootpack) + - `subjects`: OCI digests, Merkle roots per layer +- **File store** + - Bundles stored as `.tar.zst` + +--- + +## Developer Checklist + +- [ ] All inputs (feeds, policies, tools, env) hashed and recorded. +- [ ] JSON normalization: key order, number format, newline mode. +- [ ] Random seed = `H(scan.id || MerkleRootAllLayers)`. +- [ ] Clock fixed to `scan.time` unless policy requires “now”. +- [ ] DSSE multi-sig supported (FIPS + regional). +- [ ] Manifest signed + optionally anchored to Rekor ledger. +- [ ] Replay comparison mode tested across x64/arm64. + +--- + +## References +See also: +- `DETERMINISTIC_REPLAY.md` — detailed manifest schema & CLI examples. +- `../docs/CRYPTO_SOVEREIGN_READY.md` — RootPack and dual-signature handling. + +--- diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs index 6e6f95b7..2b0f10e6 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs @@ -110,6 +110,24 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture>(); Assert.True(options.Value.Bootstrap.Enabled); + var seededAccount = Assert.Single(options.Value.Delegation.ServiceAccounts); + Assert.True(seededAccount.Enabled); + var accountStore = scope.ServiceProvider.GetRequiredService(); + var existingDocument = await accountStore.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); + var document = existingDocument ?? new AuthorityServiceAccountDocument { AccountId = ServiceAccountId }; + document.Tenant = TenantId; + document.DisplayName = "Observability Exporter"; + document.Description = "Automates evidence exports."; + document.Enabled = true; + document.AllowedScopes = new List { "jobs:read", "findings:read" }; + document.AuthorizedClients = new List { "export-center-worker" }; + document.Attributes = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["env"] = new List { "prod" }, + ["owner"] = new List { "vuln-team" }, + ["business_tier"] = new List { "tier-1" } + }; + await accountStore.UpsertAsync(document, CancellationToken.None); var endpoints = scope.ServiceProvider.GetRequiredService().Endpoints; var serviceAccountsEndpoint = endpoints .OfType() @@ -142,6 +160,14 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture(); + var document = await accountStore.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); + Assert.NotNull(document); + Assert.True(document!.Enabled); + } } [Fact] @@ -165,7 +191,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture(); - var session = await sessionAccessor.GetSessionAsync().ConfigureAwait(false); - var token = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session).ConfigureAwait(false); + var session = await sessionAccessor.GetSessionAsync(); + var token = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session); Assert.NotNull(token); Assert.Equal("revoked", token!.Status); } } - var audit = Assert.Single(sink.Events.Where(evt => evt.EventType == "authority.delegation.revoked")); + var audit = Assert.Single(sink.Events, evt => evt.EventType == "authority.delegation.revoked"); Assert.Equal(AuthEventOutcome.Success, audit.Outcome); Assert.Equal("operator_request", audit.Reason); Assert.Contains(audit.Properties, property => @@ -389,7 +415,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture(); - var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false); + var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); Assert.NotNull(document); initialId = document!.Id; @@ -502,7 +528,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture(); - var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false); + var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); Assert.NotNull(document); Assert.Equal(initialId, document!.Id); @@ -521,6 +547,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture string.Equals(account.AccountId, ServiceAccountId, StringComparison.OrdinalIgnoreCase)); + + if (serviceAccount is null) { - var serviceAccount = new AuthorityServiceAccountSeedOptions - { - AccountId = ServiceAccountId, - Tenant = TenantId, - DisplayName = "Observability Exporter", - Description = "Automates evidence exports." - }; - - serviceAccount.AllowedScopes.Add("jobs:read"); - serviceAccount.AllowedScopes.Add("findings:read"); - serviceAccount.AuthorizedClients.Add("export-center-worker"); - serviceAccount.Attributes["env"] = new List { "prod" }; - serviceAccount.Attributes["owner"] = new List { "vuln-team" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - + serviceAccount = new AuthorityServiceAccountSeedOptions(); options.Delegation.ServiceAccounts.Add(serviceAccount); } + + serviceAccount.AccountId = ServiceAccountId; + serviceAccount.Tenant = TenantId; + serviceAccount.DisplayName = "Observability Exporter"; + serviceAccount.Description = "Automates evidence exports."; + serviceAccount.Enabled = true; + + serviceAccount.AllowedScopes.Clear(); + serviceAccount.AllowedScopes.Add("jobs:read"); + serviceAccount.AllowedScopes.Add("findings:read"); + + serviceAccount.AuthorizedClients.Clear(); + serviceAccount.AuthorizedClients.Add("export-center-worker"); + + serviceAccount.Attributes["env"] = new List { "prod" }; + serviceAccount.Attributes["owner"] = new List { "vuln-team" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; }); }); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs index 0d498dbf..91b01b9f 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs @@ -52,11 +52,11 @@ public sealed class ConsoleEndpointsTests Assert.Equal(1, tenants.GetArrayLength()); Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString()); - var events = sink.Events; - var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize")); - Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome); - - var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.tenants.read")); + var events = sink.Events; + var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize"); + Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome); + + var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read"); Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome); Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name)); Assert.Equal(2, events.Count); @@ -146,10 +146,10 @@ public sealed class ConsoleEndpointsTests Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString()); var events = sink.Events; - var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize")); + var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize"); Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome); - var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.profile.read")); + var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.profile.read"); Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome); Assert.Equal(2, events.Count); } @@ -184,10 +184,10 @@ public sealed class ConsoleEndpointsTests Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString()); var events = sink.Events; - var authorizeEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.resource.authorize")); + var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize"); Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome); - var consoleEvent = Assert.Single(events.Where(evt => evt.EventType == "authority.console.token.introspect")); + var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect"); Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome); Assert.Equal(2, events.Count); } @@ -287,7 +287,7 @@ public sealed class ConsoleEndpointsTests app.UseAuthorization(); app.MapConsoleEndpoints(); - await app.StartAsync().ConfigureAwait(false); + await app.StartAsync(); return app; } @@ -321,12 +321,11 @@ public sealed class ConsoleEndpointsTests private sealed class TestAuthenticationHandler : AuthenticationHandler { - public TestAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : base(options, logger, encoder, clock) + public TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) { } @@ -356,4 +355,4 @@ internal static class HostTestClientExtensions internal static class TestAuthenticationDefaults { public const string AuthenticationScheme = "AuthorityConsoleTests"; -} \ No newline at end of file +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs index dcbf9767..264795de 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs @@ -31,6 +31,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory DisposeAsync().AsTask(); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs new file mode 100644 index 00000000..739cc6c2 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Authority.Tests.Infrastructure; + +internal sealed class EnvironmentVariableScope : IDisposable +{ + private readonly Dictionary originals = new(StringComparer.Ordinal); + private bool disposed; + + public EnvironmentVariableScope(IEnumerable> overrides) + { + if (overrides is null) + { + throw new ArgumentNullException(nameof(overrides)); + } + + foreach (var kvp in overrides) + { + if (originals.ContainsKey(kvp.Key)) + { + continue; + } + + originals.Add(kvp.Key, Environment.GetEnvironmentVariable(kvp.Key)); + Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + foreach (var kvp in originals) + { + Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); + } + + disposed = true; + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs index 93b691e8..b0b6785e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs @@ -17,9 +17,8 @@ internal sealed class TestAuthHandler : AuthenticationHandler options, ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : base(options, logger, encoder, clock) + UrlEncoder encoder) + : base(options, logger, encoder) { } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs index b78ecf20..8d5ed27a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs @@ -35,6 +35,14 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture(AckEnabledKey, "true"), + new KeyValuePair(AckActiveKeyIdKey, "ack-key-1"), + new KeyValuePair(AckKeyPathKey, key1Path), + new KeyValuePair(AckKeySourceKey, "file"), + new KeyValuePair(AckAlgorithmKey, SignatureAlgorithms.Es256), + new KeyValuePair(WebhooksEnabledKey, "true"), + new KeyValuePair(WebhooksAllowedHost0Key, "hooks.slack.com") + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z")); - using var app = factory.WithWebHostBuilder(host => + using var scopedFactory = factory.WithWebHostBuilder(host => { host.ConfigureAppConfiguration((_, configuration) => { @@ -87,7 +106,8 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture evt.EventType == "notify.ack.key_rotated")); + var rotationEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotated"); Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome); Assert.Contains(rotationEvent.Properties, property => string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) && @@ -184,7 +204,7 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture evt.EventType == "notify.ack.key_rotation_failed")); + var failureEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotation_failed"); Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome); Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenApi/OpenApiDiscoveryEndpointTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenApi/OpenApiDiscoveryEndpointTests.cs index 0d31764d..00393680 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenApi/OpenApiDiscoveryEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenApi/OpenApiDiscoveryEndpointTests.cs @@ -27,7 +27,7 @@ public sealed class OpenApiDiscoveryEndpointTests : IClassFixture.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -296,7 +296,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -346,7 +346,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -398,10 +398,10 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -464,10 +464,10 @@ public class ClientCredentialsHandlersTests { AccessTokenLifetime = TimeSpan.FromMinutes(5) }; - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validateHandler.HandleAsync(validateContext); @@ -1018,8 +1018,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1056,7 +1056,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1093,7 +1093,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1129,8 +1129,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1167,7 +1167,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1204,7 +1204,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1242,8 +1242,8 @@ public class ClientCredentialsHandlersTests var longReason = new string('a', 257); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1281,8 +1281,8 @@ public class ClientCredentialsHandlersTests var longTicket = new string('b', 129); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1319,8 +1319,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1361,8 +1361,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1406,8 +1406,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName, "raise export center quota for tenant"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaTicketParameterName, "CHG-7721"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "raise export center quota for tenant"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "CHG-7721"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1481,7 +1481,7 @@ public class ClientCredentialsHandlersTests var longReason = new string('a', 257); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1518,8 +1518,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName, "increase concurrency to unblock digests"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaTicketParameterName, new string('b', 129)); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "increase concurrency to unblock digests"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, new string('b', 129)); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1556,7 +1556,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1598,8 +1598,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaReasonParameterName, "temporary burst for export audit"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.QuotaTicketParameterName, "RFC-5541"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "temporary burst for export audit"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "RFC-5541"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1796,7 +1796,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1832,7 +1832,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -1868,8 +1868,8 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); - transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await handler.HandleAsync(context); @@ -2310,7 +2310,7 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Request?.SetParameter("unexpected_param", "value"); + SetParameter(transaction,"unexpected_param", "value"); await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); @@ -2522,8 +2522,9 @@ public class ClientCredentialsHandlersTests Assert.True(validateContext.IsRejected); Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); - var authenticateHeader = Assert.Single(httpContext.Response.Headers.Select(header => header) - .Where(header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase))).Value; + var authenticateHeader = Assert.Single( + httpContext.Response.Headers, + header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase)).Value; Assert.Contains("use_dpop_nonce", authenticateHeader.ToString()); Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues)); Assert.False(StringValues.IsNullOrEmpty(nonceValues)); @@ -2862,7 +2863,7 @@ public class ClientCredentialsHandlersTests var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validateHandler.HandleAsync(validateContext); @@ -2951,10 +2952,10 @@ public class ClientCredentialsHandlersTests NullLogger.Instance); var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops"); - transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validateHandler.HandleAsync(validateContext); @@ -3071,7 +3072,7 @@ public class TokenValidationHandlersTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) { Principal = principal, @@ -3125,7 +3126,7 @@ public class TokenValidationHandlersTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta")); var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) { @@ -3175,7 +3176,7 @@ public class TokenValidationHandlersTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) { Principal = principal, @@ -3224,7 +3225,7 @@ public class TokenValidationHandlersTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")); var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) { @@ -3326,7 +3327,7 @@ public class TokenValidationHandlersTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument)); var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) { Principal = principal, @@ -3963,7 +3964,7 @@ public class ObservabilityIncidentTokenHandlerTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-incident", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-incident", ResolveProvider(clientDocument)); principal.SetScopes(StellaOpsScopes.ObservabilityIncident); var staleAuthTime = DateTimeOffset.UtcNow.AddMinutes(-10); principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); @@ -4018,7 +4019,7 @@ public class ObservabilityIncidentTokenHandlerTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", ResolveProvider(clientDocument)); principal.SetScopes(StellaOpsScopes.PolicyPublish); principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); @@ -4075,7 +4076,7 @@ public class ObservabilityIncidentTokenHandlerTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", ResolveProvider(clientDocument)); principal.SetScopes(StellaOpsScopes.PolicyPublish); principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('a', 64)); @@ -4136,7 +4137,7 @@ public class ObservabilityIncidentTokenHandlerTests Request = new OpenIddictRequest() }; - var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", clientDocument.Plugin); + var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", ResolveProvider(clientDocument)); principal.SetScopes(scope); principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, expectedOperation); principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('b', 64)); @@ -4245,6 +4246,27 @@ internal static class TestHelpers private static string? NormalizeTenant(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + public static string ResolveProvider(AuthorityClientDocument document) + => string.IsNullOrWhiteSpace(document.Plugin) ? "standard" : document.Plugin; + + private static OpenIddictRequest GetRequest(OpenIddictServerTransaction transaction) + => transaction.Request ?? throw new InvalidOperationException("OpenIddict request is required for this test."); + + public static void SetParameter(OpenIddictServerTransaction transaction, string name, object? value) + { + var parameter = value switch + { + null => default, + OpenIddictParameter existing => existing, + string s => new OpenIddictParameter(s), + bool b => new OpenIddictParameter(b), + int i => new OpenIddictParameter(i), + long l => new OpenIddictParameter(l), + _ => new OpenIddictParameter(value?.ToString()) + }; + GetRequest(transaction).SetParameter(name, parameter); + } + public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document) { var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs index 489bbc52..02203011 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs @@ -21,11 +21,11 @@ public sealed class DiscoveryMetadataTests : IClassFixture { ["grant_type"] = "client_credentials" - })).ConfigureAwait(false); + })); Assert.NotNull(response); Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues)); @@ -77,7 +77,7 @@ public sealed class LegacyAuthDeprecationTests : IClassFixture { ["grant_type"] = "client_credentials" - })).ConfigureAwait(false); + })); Assert.NotNull(response); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs index 6afad5e2..a5b45e89 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs @@ -122,7 +122,7 @@ public class PasswordGrantHandlersTests var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); - transaction.Request.SetParameter("incident_reason", "Sev1 drill activation"); + SetParameter(transaction, "incident_reason", "Sev1 drill activation"); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(validateContext); @@ -213,8 +213,8 @@ public class PasswordGrantHandlersTests var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - transaction.Request.SetParameter("policy_ticket", "CR-1001"); - transaction.Request.SetParameter("policy_digest", new string('a', 64)); + SetParameter(transaction, "policy_ticket", "CR-1001"); + SetParameter(transaction, "policy_digest", new string('a', 64)); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(context); @@ -239,8 +239,8 @@ public class PasswordGrantHandlersTests var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - transaction.Request.SetParameter("policy_reason", "Publish approved policy"); - transaction.Request.SetParameter("policy_digest", new string('b', 64)); + SetParameter(transaction, "policy_reason", "Publish approved policy"); + SetParameter(transaction, "policy_digest", new string('b', 64)); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(context); @@ -261,8 +261,8 @@ public class PasswordGrantHandlersTests var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - transaction.Request.SetParameter("policy_reason", "Publish approved policy"); - transaction.Request.SetParameter("policy_ticket", "CR-1002"); + SetParameter(transaction, "policy_reason", "Publish approved policy"); + SetParameter(transaction, "policy_ticket", "CR-1002"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(context); @@ -283,9 +283,9 @@ public class PasswordGrantHandlersTests var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - transaction.Request.SetParameter("policy_reason", "Publish approved policy"); - transaction.Request.SetParameter("policy_ticket", "CR-1003"); - transaction.Request.SetParameter("policy_digest", "not-hex"); + SetParameter(transaction, "policy_reason", "Publish approved policy"); + SetParameter(transaction, "policy_ticket", "CR-1003"); + SetParameter(transaction, "policy_digest", "not-hex"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(context); @@ -311,9 +311,9 @@ public class PasswordGrantHandlersTests var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!", scope); - transaction.Request.SetParameter("policy_reason", "Promote approved policy"); - transaction.Request.SetParameter("policy_ticket", "CR-1004"); - transaction.Request.SetParameter("policy_digest", new string('c', 64)); + SetParameter(transaction, "policy_reason", "Promote approved policy"); + SetParameter(transaction, "policy_ticket", "CR-1004"); + SetParameter(transaction, "policy_digest", new string('c', 64)); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); await validate.HandleAsync(validateContext); @@ -403,7 +403,7 @@ public class PasswordGrantHandlersTests var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); var transaction = CreatePasswordTransaction("alice", "Password1!"); - transaction.Request?.SetParameter("unexpected_param", "value"); + SetParameter(transaction, "unexpected_param", "value"); await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); @@ -506,6 +506,22 @@ public class PasswordGrantHandlersTests }; } + private static void SetParameter(OpenIddictServerTransaction transaction, string name, object? value) + { + var request = transaction.Request ?? throw new InvalidOperationException("OpenIddict request is required for this test."); + var parameter = value switch + { + null => default, + OpenIddictParameter existing => existing, + string s => new OpenIddictParameter(s), + bool b => new OpenIddictParameter(b), + int i => new OpenIddictParameter(i), + long l => new OpenIddictParameter(l), + _ => new OpenIddictParameter(value?.ToString()) + }; + request.SetParameter(name, parameter); + } + private static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? configure = null) { var options = new StellaOpsAuthorityOptions diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/RateLimiting/AuthorityRateLimiterIntegrationTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/RateLimiting/AuthorityRateLimiterIntegrationTests.cs index 8c480130..33a7423a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/RateLimiting/AuthorityRateLimiterIntegrationTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/RateLimiting/AuthorityRateLimiterIntegrationTests.cs @@ -3,15 +3,16 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; -using StellaOps.Authority.RateLimiting; -using StellaOps.Configuration; -using Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Hosting; +using StellaOps.Authority.RateLimiting; +using StellaOps.Configuration; +using Xunit; namespace StellaOps.Authority.Tests.RateLimiting; @@ -91,46 +92,52 @@ public class AuthorityRateLimiterIntegrationTests configure?.Invoke(options); - var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddSingleton(options); - services.AddSingleton>(Options.Create(options)); - services.TryAddSingleton(_ => TimeProvider.System); - services.AddHttpContextAccessor(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.AddRateLimiter(rateLimiterOptions => - { - AuthorityRateLimiter.Configure(rateLimiterOptions, options); - }); - }) - .Configure(app => - { - app.UseAuthorityRateLimiterContext(); - app.UseRateLimiter(); - - app.Map("/token", builder => - { - builder.Run(async context => - { - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("token-ok"); - }); - }); - - app.Map("/internal/ping", builder => - { - builder.Run(async context => - { - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("internal-ok"); - }); - }); - }); - - return new TestServer(builder); - } + var hostBuilder = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddSingleton(options); + services.AddSingleton>(Options.Create(options)); + services.TryAddSingleton(_ => TimeProvider.System); + services.AddHttpContextAccessor(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddRateLimiter(rateLimiterOptions => + { + AuthorityRateLimiter.Configure(rateLimiterOptions, options); + }); + }); + + web.Configure(app => + { + app.UseAuthorityRateLimiterContext(); + app.UseRateLimiter(); + + app.Map("/token", tokenBuilder => + { + tokenBuilder.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("token-ok"); + }); + }); + + app.Map("/internal/ping", internalBuilder => + { + internalBuilder.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("internal-ok"); + }); + }); + }); + }); + + var host = hostBuilder.Start(); + return host.GetTestServer() ?? throw new InvalidOperationException("Failed to create TestServer."); + } private static FormUrlEncodedContent CreateTokenForm(string clientId) => new(new Dictionary diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs index 691252c8..769c0237 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs @@ -28,6 +28,11 @@ namespace StellaOps.Authority.Tests.Vulnerability; public sealed class VulnWorkflowTokenEndpointTests : IClassFixture { private readonly AuthorityWebApplicationFactory factory; + private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED"; + private const string SigningActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID"; + private const string SigningKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH"; + private const string SigningKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYSOURCE"; + private const string SigningAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ALGORITHM"; public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory) { @@ -44,6 +49,15 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture(SigningEnabledKey, "true"), + new KeyValuePair(SigningActiveKeyIdKey, "workflow-key"), + new KeyValuePair(SigningKeyPathKey, keyPath), + new KeyValuePair(SigningKeySourceKey, "file"), + new KeyValuePair(SigningAlgorithmKey, SignatureAlgorithms.Es256) + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z")); @@ -94,10 +108,10 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture evt.EventType == "vuln.workflow.csrf.issued")); + var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor"); - var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.verified")); + var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified"); Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456"); } finally @@ -116,6 +130,15 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture(SigningEnabledKey, "true"), + new KeyValuePair(SigningActiveKeyIdKey, "workflow-key"), + new KeyValuePair(SigningKeyPathKey, keyPath), + new KeyValuePair(SigningKeySourceKey, "file"), + new KeyValuePair(SigningAlgorithmKey, SignatureAlgorithms.Es256) + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z")); @@ -158,6 +181,15 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture(SigningEnabledKey, "true"), + new KeyValuePair(SigningActiveKeyIdKey, "workflow-key"), + new KeyValuePair(SigningKeyPathKey, keyPath), + new KeyValuePair(SigningKeySourceKey, "file"), + new KeyValuePair(SigningAlgorithmKey, SignatureAlgorithms.Es256) + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z")); @@ -196,7 +228,7 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture evt.EventType == "vuln.workflow.csrf.issued")); + Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified"); } finally @@ -215,6 +247,15 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture(SigningEnabledKey, "true"), + new KeyValuePair(SigningActiveKeyIdKey, "attachment-key"), + new KeyValuePair(SigningKeyPathKey, keyPath), + new KeyValuePair(SigningKeySourceKey, "file"), + new KeyValuePair(SigningAlgorithmKey, SignatureAlgorithms.Es256) + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z")); @@ -233,7 +274,7 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture { ["origin"] = "vuln-workflow" } + Metadata = new Dictionary { ["origin"] = "vuln-workflow" } }; var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload); @@ -256,10 +297,10 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture evt.EventType == "vuln.attachment.token.issued")); + var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued"); Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001"); - var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.verified")); + var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified"); Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001"); } finally @@ -278,6 +319,15 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture(SigningEnabledKey, "true"), + new KeyValuePair(SigningActiveKeyIdKey, "attachment-key"), + new KeyValuePair(SigningKeyPathKey, keyPath), + new KeyValuePair(SigningKeySourceKey, "file"), + new KeyValuePair(SigningAlgorithmKey, SignatureAlgorithms.Es256) + }); + var sink = new RecordingAuthEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z")); @@ -316,7 +366,7 @@ public sealed class VulnWorkflowTokenEndpointTests : IClassFixture evt.EventType == "vuln.attachment.token.issued")); + Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued"); Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified"); } finally diff --git a/src/Authority/StellaOps.Authority/TASKS.md b/src/Authority/StellaOps.Authority/TASKS.md index a10337db..260bfd6a 100644 --- a/src/Authority/StellaOps.Authority/TASKS.md +++ b/src/Authority/StellaOps.Authority/TASKS.md @@ -134,6 +134,7 @@ > 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run. > 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence. > 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour. +> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios. ## Observability & Forensics (Epic 15)