diff --git a/docs/09_API_CLI_REFERENCE.md b/docs/09_API_CLI_REFERENCE.md index de49a92d..34f15e15 100755 --- a/docs/09_API_CLI_REFERENCE.md +++ b/docs/09_API_CLI_REFERENCE.md @@ -323,17 +323,40 @@ Accept: application/json **Response 200**: ```json -{ - "scanId": "2f6c17f9b3f548e2a28b9c412f4d63f8", - "status": "Pending", - "image": { - "reference": "registry.example.com/acme/app:1.2.3", - "digest": null - }, - "createdAt": "2025-10-18T20:15:12.482Z", - "updatedAt": "2025-10-18T20:15:12.482Z", - "failureReason": null -} +{ + "scanId": "2f6c17f9b3f548e2a28b9c412f4d63f8", + "status": "Pending", + "image": { + "reference": "registry.example.com/acme/app:1.2.3", + "digest": "sha256:cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe" + }, + "createdAt": "2025-10-18T20:15:12.482Z", + "updatedAt": "2025-10-18T20:15:12.482Z", + "failureReason": null, + "surface": { + "tenant": "default", + "generatedAt": "2025-10-18T20:15:12.482Z", + "manifestDigest": "sha256:8b4ddf1a9d3565eb7c2b176a0a64a970795e5ec373dbea3aaebb4208f9759b44", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/default/sha256/8b/4d/8b4ddf1a9d3565eb7c2b176a0a64a970795e5ec373dbea3aaebb4208f9759b44.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "default", + "imageDigest": "sha256:cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe", + "generatedAt": "2025-10-18T20:15:12.482Z", + "artifacts": [ + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe/sbom.cdx.json", + "digest": "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "mediaType": "application/vnd.cyclonedx+json; version=1.6; view=inventory", + "format": "cdx-json", + "sizeBytes": 2048, + "view": "inventory" + } + ] + } + } +} ``` Statuses: `Pending`, `Running`, `Succeeded`, `Failed`, `Cancelled`. @@ -445,8 +468,40 @@ Request body mirrors policy preview inputs (image digest plus findings). The ser "reachability": "runtime" } ], - "issues": [] - }, + "issues": [], + "surface": { + "tenant": "default", + "generatedAt": "2025-10-23T15:32:22Z", + "manifestDigest": "sha256:1f3c5d7a8e4b921a0c2f6b8de0bb5aa8f5aa51b62e7d7d1864f9c826bfb44d91", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/default/sha256/1f/3c/1f3c5d7a8e4b921a0c2f6b8de0bb5aa8f5aa51b62e7d7d1864f9c826bfb44d91.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "default", + "imageDigest": "sha256:7dbe0c9a5d4f1c8184007e9d94dbe55928f8a2db5ab9c1c2d4a2f7bbcdfe1234", + "generatedAt": "2025-10-23T15:32:22Z", + "artifacts": [ + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/7dbe0c9a5d4f1c8184007e9d94dbe55928f8a2db5ab9c1c2d4a2f7bbcdfe1234/sbom.cdx.json", + "digest": "sha256:2b8ce7dd0037e59f0f93e4a5cff45b1eb305a511a1c9e2895d2f4ecdf616d3da", + "mediaType": "application/vnd.cyclonedx+json; version=1.6; view=inventory", + "format": "cdx-json", + "sizeBytes": 3072, + "view": "inventory" + }, + { + "kind": "sbom-usage", + "uri": "cas://scanner-artifacts/scanner/images/7dbe0c9a5d4f1c8184007e9d94dbe55928f8a2db5ab9c1c2d4a2f7bbcdfe1234/sbom.cdx.pb", + "digest": "sha256:74e4d9f8ab0f2a1772e5768e15a5a9d7b662b849b1f223c8d6f3b184e4ac7780", + "mediaType": "application/vnd.cyclonedx+protobuf; version=1.6; view=usage", + "format": "cdx-protobuf", + "sizeBytes": 12800, + "view": "usage" + } + ] + } + } + }, "dsse": { "payloadType": "application/vnd.stellaops.report+json", "payload": "eyJyZXBvcnQiOnsicmVwb3J0SWQiOiJyZXBvcnQtOWY4Y2RlMjFhYWI1NDMyMSJ9fQ==", @@ -461,7 +516,7 @@ Request body mirrors policy preview inputs (image digest plus findings). The ser } ``` -- The `report` object omits null fields and is deterministic (ISO timestamps, sorted keys) while surfacing `unknownConfidence`, `confidenceBand`, and `unknownAgeDays` for auditability. +- The `report` object omits null fields and is deterministic (ISO timestamps, sorted keys) while surfacing `unknownConfidence`, `confidenceBand`, `unknownAgeDays`, and a `surface` block containing the manifest digest and CAS URIs for downstream tooling. - `dsse` follows the DSSE (Dead Simple Signing Envelope) shape; `payload` is the canonical UTF-8 JSON and `signatures[0].signature` is the base64 HMAC/Ed25519 value depending on configuration. - Full offline samples live at `samples/policy/policy-report-unknown.json` (request + response) and `samples/api/reports/report-sample.dsse.json` (envelope fixture) for tooling tests or signature verification. diff --git a/docs/api/policy.md b/docs/api/policy.md index c616a36f..819a7f46 100644 --- a/docs/api/policy.md +++ b/docs/api/policy.md @@ -326,7 +326,80 @@ Produces sealed bundle for determinism verification; returns location of bundle. --- -## 7 · Effective Findings APIs +## 7 · Batch Evaluation API + +Deterministic evaluator for downstream services (Findings Ledger, replay tooling, offline exporters). Consumers submit ledger event payloads and receive policy verdicts with rationale lists; no state is persisted in Policy Engine. + +``` +POST /api/policy/eval/batch +Scopes: policy:simulate (service identities only) +Headers: X-Stella-Tenant, Idempotency-Key (optional) +``` + +**Request** + +```jsonc +{ + "tenantId": "acme", + "policyVersion": "sha256:1fb2…", + "items": [ + { + "findingId": "acme::artifact-1::CVE-2024-12345", + "eventId": "5d1fcc61-6903-42ef-9285-7f4d3d8f7f69", + "event": { ... canonical ledger payload ... }, + "currentProjection": { + "status": "triaged", + "severity": 3.4, + "labels": { "exposure": "runtime" }, + "explainRef": "policy://explain/123", + "rationale": ["policy://explain/123"] + } + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `tenantId` | Must match the `X-Stella-Tenant` header. | +| `policyVersion` | Deterministic policy digest (for example `sha256:`). Required for caching. | +| `event` | Canonical ledger event payload (`ledger_events.event_body`). | +| `currentProjection` | Optional snapshot of the existing finding projection. Null values are ignored. | + +**Response 200** + +```jsonc +{ + "items": [ + { + "findingId": "acme::artifact-1::CVE-2024-12345", + "status": "affected", + "severity": 7.5, + "labels": { "exposure": "runtime" }, + "explainRef": "policy://explain/123", + "rationale": [ + "policy://explain/123", + "policy://remediation/321" + ] + } + ], + "cost": { + "units": 1, + "budgetRemaining": 999 + } +} +``` + +Notes: + +- Items that cannot be evaluated return `status: null` with an `error` object. Callers should fall back to inline evaluation. +- Policy Engine enforces per-tenant cost budgets; batches that exceed the remaining allowance receive `429 Too Many Requests`. +- Responses are deterministic; clients may cache results by `(tenantId, policyVersion, eventHash, projectionHash)` to support replay/offline parity. +- Standard `ERR_POL_*` payloads surface errors; `ERR_POL_006` indicates the evaluator aborted the batch. + +--- + +## 8 · Effective Findings APIs ### 7.1 List Findings @@ -378,7 +451,7 @@ Returns rule hit sequence: --- -## 8 · Events & Webhooks +## 9 · Events & Webhooks - `policy.run.completed` – emitted with `runId`, `policyId`, `mode`, `stats`, `determinismHash`. - `policy.run.failed` – includes error code, retry count, guidance. @@ -387,7 +460,7 @@ Returns rule hit sequence: --- -## 9 · Compliance Checklist +## 10 · Compliance Checklist - [ ] **Scopes enforced:** Endpoint access requires correct Authority scope mapping (see `/src/Authority/StellaOps.Authority/TASKS.md`). - [ ] **Schemas current:** JSON examples align with Scheduler Models (`SCHED-MODELS-20-001`) and Policy Engine DTOs; update when contracts change. diff --git a/docs/dev/normalized-rule-recipes.md b/docs/dev/normalized-rule-recipes.md index c85fcde8..b840f762 100644 --- a/docs/dev/normalized-rule-recipes.md +++ b/docs/dev/normalized-rule-recipes.md @@ -12,6 +12,24 @@ This guide captures the minimum wiring required for connectors and Merge coordin 4. Verify with `dotnet test` that the connector snapshot fixtures now include the `normalizedVersions` array and update fixtures by setting the connector-specific `UPDATE_*_FIXTURES=1` environment variable. 5. Tail Merge logs (or the test output) for the new warning `Normalized version rules missing for {AdvisoryKey}`; an empty warning stream means the connector/merge artefacts are ready to close FEEDMERGE-COORD-02-901/902. +### 1.1 Rollout status (2025-11-04) + +| Connector | Status | Notes / next steps | +|-----------|--------|--------------------| +| `vendor.acsc` | ⚠️ Pending | Upstream feed only supplies vendor/product strings. Waiting on ASD/ACSC feed update to expose explicit ranges. Track via FEEDCONN-ACSC-02-010. | +| `vendor.cccs` | ⚠️ Pending | Advisory payloads lack structured version ranges; all entries remain vendor identifiers. Coordinate with CCCS to obtain machine-readable version metadata or fall back to Model heuristics. | +| `certbund` | ⚠️ Pending | `product.Versions` contains natural-language German ranges. Parser spec drafted (`CERTBUND-NORM-01`); awaiting implementation before emitting normalized rules. | +| `vendor.cisco` | ⚠️ Pending | Current API exposes product IDs only. Engage Cisco PSIRT (FEEDCONN-CISCO-02-014) to surface affected version expressions. | +| `vendor.apple` | ✅ Done | SemVer-style helpers in `AppleMapper` emit normalized rules with provenance annotations. | +| `vendor.msrc` | ✅ Done | `MsrcMapper` maps KB build numbers to exact rules; normalized output guarded by fixtures. | +| `vendor.ghsa` | ✅ Done | SemVer + vendor fallback rules emitted (`CreateSemVerVersionArtifacts`). | +| `vendor.kisa` | ✅ Partial | Normalized SemVer rules for structured firmware ranges. Fallback vendor strings remain for prose-only advisories; continue capturing new patterns in fixtures. | +| `ics.cisa` | ✅ Done | Firmware helper emits normalized range matrix using `SemVerRangeRuleBuilder`. | +| `certcc` | ✅ Done | Vendor comparator transforms range fragments to normalized rules. | +| `cve.nvd` | ✅ Done | Full SemVer builder and provenance mapping rolled out (FEEDCONN-CVE-02-015). | +| `ru.bdu` | ⚠️ Pending | Feed only includes product codenames. Normalized rules blocked until Roskomnadzor publishes range schema. | +| `ru.nkcki` | ✅ Done | SemVer-style range parser covers vendor firmware records; remaining prose ranges logged with `Normalized version rules missing`. | + ## 2. Code snippet: SemVer connector (CCCS/Cisco/ICS-CISA) ```csharp diff --git a/docs/events/samples/scanner.event.report.ready@1.sample.json b/docs/events/samples/scanner.event.report.ready@1.sample.json index a7ef9dda..217ab967 100644 --- a/docs/events/samples/scanner.event.report.ready@1.sample.json +++ b/docs/events/samples/scanner.event.report.ready@1.sample.json @@ -1,101 +1,141 @@ -{ - "eventId": "6d2d1b77-f3c3-4f70-8a9d-6f2d0c8801ab", - "kind": "scanner.event.report.ready", - "version": 1, - "tenant": "tenant-alpha", - "occurredAt": "2025-10-19T12:34:56Z", - "recordedAt": "2025-10-19T12:34:57Z", - "source": "scanner.webservice", - "idempotencyKey": "scanner.event.report.ready:tenant-alpha:report-abc", - "correlationId": "report-abc", - "traceId": "0af7651916cd43dd8448eb211c80319c", - "spanId": "b7ad6b7169203331", - "scope": { - "namespace": "acme/edge", - "repo": "api", - "digest": "sha256:feedface" - }, - "attributes": { - "reportId": "report-abc", - "policyRevisionId": "rev-42", - "policyDigest": "digest-123", - "verdict": "blocked" - }, - "payload": { - "reportId": "report-abc", - "scanId": "report-abc", - "imageDigest": "sha256:feedface", - "generatedAt": "2025-10-19T12:34:56Z", - "verdict": "fail", - "summary": { - "total": 1, - "blocked": 1, - "warned": 0, - "ignored": 0, - "quieted": 0 - }, - "delta": { - "newCritical": 1, - "kev": [ - "CVE-2024-9999" - ] - }, - "quietedFindingCount": 0, - "policy": { - "digest": "digest-123", - "revisionId": "rev-42" - }, - "links": { - "report": { - "ui": "https://scanner.example/ui/reports/report-abc", - "api": "https://scanner.example/api/v1/reports/report-abc" +{ + "eventId": "6d2d1b77-f3c3-4f70-8a9d-6f2d0c8801ab", + "kind": "scanner.event.report.ready", + "version": 1, + "tenant": "tenant-alpha", + "occurredAt": "2025-10-19T12:34:56+00:00", + "recordedAt": "2025-10-19T12:34:57+00:00", + "source": "scanner.webservice", + "idempotencyKey": "scanner.event.report.ready:tenant-alpha:report-abc", + "correlationId": "report-abc", + "traceId": "0af7651916cd43dd8448eb211c80319c", + "spanId": "b7ad6b7169203331", + "scope": { + "namespace": "acme/edge", + "repo": "api", + "digest": "sha256:feedface" + }, + "payload": { + "reportId": "report-abc", + "scanId": "report-abc", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "verdict": "fail", + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 }, + "delta": { + "newCritical": 1, + "kev": [ + "CVE-2024-9999" + ] + }, + "quietedFindingCount": 0, "policy": { - "ui": "https://scanner.example/ui/policy/revisions/rev-42", - "api": "https://scanner.example/api/v1/policy/revisions/rev-42" + "revisionId": "rev-42", + "digest": "digest-123" }, - "attestation": { - "ui": "https://scanner.example/ui/attestations/report-abc", - "api": "https://scanner.example/api/v1/reports/report-abc/attestation" + "links": { + "report": { + "ui": "https://scanner.example/ui/reports/report-abc", + "api": "https://scanner.example/api/v1/reports/report-abc" + }, + "policy": { + "ui": "https://scanner.example/ui/policy/revisions/rev-42", + "api": "https://scanner.example/api/v1/policy/revisions/rev-42" + }, + "attestation": { + "ui": "https://scanner.example/ui/attestations/report-abc", + "api": "https://scanner.example/api/v1/reports/report-abc/attestation" + } + }, + "dsse": { + "payloadType": "application/vnd.stellaops.report+json", + "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJyZWFjaGFiaWxpdHkiOiJydW50aW1lIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwic3RhdHVzIjoiQmxvY2tlZCJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHgranNvbjt2ZXJzaW9uPTEuNjt2aWV3PWludmVudG9yeSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoyNDU3NiwidmlldyI6ImludmVudG9yeSJ9LHsia2luZCI6InNib20tdXNhZ2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2ltYWdlcy9mZWVkZmFjZS9zYm9tLXVzYWdlLmNkeC5qc29uIiwiZGlnZXN0Ijoic2hhMjU2OjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIiLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuY3ljbG9uZWR4K2pzb247dmVyc2lvbj0xLjY7dmlldz11c2FnZSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoxNjM4NCwidmlldyI6InVzYWdlIn1dfX19", + "signatures": [ + { + "keyId": "test-key", + "algorithm": "hs256", + "signature": "signature-value" + } + ] + }, + "report": { + "reportId": "report-abc", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "verdict": "blocked", + "policy": { + "revisionId": "rev-42", + "digest": "digest-123" + }, + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 + }, + "verdicts": [ + { + "findingId": "finding-1", + "reachability": "runtime", + "score": 47.5, + "sourceTrust": "NVD", + "status": "Blocked" + } + ], + "issues": [], + "surface": { + "tenant": "tenant-alpha", + "generatedAt": "2025-10-19T12:34:56+00:00", + "manifestDigest": "sha256:4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/tenant-alpha/sha256/4f/ee/4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "tenant-alpha", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "artifacts": [ + { + "kind": "entry-trace", + "uri": "cas://scanner-artifacts/scanner/entry-trace/f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0/entry-trace.json", + "digest": "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + "mediaType": "application/json", + "format": "json", + "sizeBytes": 4096 + }, + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom.cdx.json", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=inventory", + "format": "cdx-json", + "sizeBytes": 24576, + "view": "inventory" + }, + { + "kind": "sbom-usage", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom-usage.cdx.json", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=usage", + "format": "cdx-json", + "sizeBytes": 16384, + "view": "usage" + } + ] + } + } } }, - "dsse": { - "payloadType": "application/vnd.stellaops.report+json", - "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXX0=", - "signatures": [ - { - "keyId": "test-key", - "algorithm": "hs256", - "signature": "signature-value" - } - ] - }, - "report": { - "reportId": "report-abc", - "generatedAt": "2025-10-19T12:34:56Z", - "imageDigest": "sha256:feedface", - "policy": { - "digest": "digest-123", - "revisionId": "rev-42" - }, - "summary": { - "total": 1, - "blocked": 1, - "warned": 0, - "ignored": 0, - "quieted": 0 - }, - "verdict": "blocked", - "verdicts": [ - { - "findingId": "finding-1", - "status": "Blocked", - "score": 47.5, - "sourceTrust": "NVD", - "reachability": "runtime" - } - ], - "issues": [] - } - } -} + "attributes": { + "policyDigest": "digest-123", + "policyRevisionId": "rev-42", + "reportId": "report-abc", + "verdict": "blocked" + } +} diff --git a/docs/events/samples/scanner.event.scan.completed@1.sample.json b/docs/events/samples/scanner.event.scan.completed@1.sample.json index 8559a9ba..83559ec2 100644 --- a/docs/events/samples/scanner.event.scan.completed@1.sample.json +++ b/docs/events/samples/scanner.event.scan.completed@1.sample.json @@ -1,56 +1,50 @@ -{ - "eventId": "08a6de24-4a94-4d14-8432-9d14f36f6da3", - "kind": "scanner.event.scan.completed", - "version": 1, - "tenant": "tenant-alpha", - "occurredAt": "2025-10-19T12:34:56Z", - "recordedAt": "2025-10-19T12:34:57Z", - "source": "scanner.webservice", - "idempotencyKey": "scanner.event.scan.completed:tenant-alpha:report-abc", - "correlationId": "report-abc", - "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", - "scope": { - "namespace": "acme/edge", - "repo": "api", - "digest": "sha256:feedface" - }, - "attributes": { - "reportId": "report-abc", - "policyRevisionId": "rev-42", - "policyDigest": "digest-123", - "verdict": "blocked" - }, - "payload": { - "reportId": "report-abc", - "scanId": "report-abc", - "imageDigest": "sha256:feedface", - "verdict": "fail", - "summary": { - "total": 1, - "blocked": 1, - "warned": 0, - "ignored": 0, - "quieted": 0 - }, - "delta": { - "newCritical": 1, - "kev": [ - "CVE-2024-9999" - ] - }, - "policy": { - "digest": "digest-123", - "revisionId": "rev-42" - }, - "findings": [ - { - "id": "finding-1", - "severity": "Critical", - "cve": "CVE-2024-9999", - "purl": "pkg:docker/acme/edge-api@sha256-feedface", - "reachability": "runtime" - } - ], +{ + "eventId": "08a6de24-4a94-4d14-8432-9d14f36f6da3", + "kind": "scanner.event.scan.completed", + "version": 1, + "tenant": "tenant-alpha", + "occurredAt": "2025-10-19T12:34:56+00:00", + "recordedAt": "2025-10-19T12:34:57+00:00", + "source": "scanner.webservice", + "idempotencyKey": "scanner.event.scan.completed:tenant-alpha:report-abc", + "correlationId": "report-abc", + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "scope": { + "namespace": "acme/edge", + "repo": "api", + "digest": "sha256:feedface" + }, + "payload": { + "reportId": "report-abc", + "scanId": "report-abc", + "imageDigest": "sha256:feedface", + "verdict": "fail", + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 + }, + "delta": { + "newCritical": 1, + "kev": [ + "CVE-2024-9999" + ] + }, + "policy": { + "revisionId": "rev-42", + "digest": "digest-123" + }, + "findings": [ + { + "id": "finding-1", + "severity": "Critical", + "cve": "CVE-2024-9999", + "purl": "pkg:docker/acme/edge-api@sha256-feedface", + "reachability": "runtime" + } + ], "links": { "report": { "ui": "https://scanner.example/ui/reports/report-abc", @@ -65,43 +59,89 @@ "api": "https://scanner.example/api/v1/reports/report-abc/attestation" } }, - "dsse": { - "payloadType": "application/vnd.stellaops.report+json", - "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXX0=", - "signatures": [ - { - "keyId": "test-key", - "algorithm": "hs256", - "signature": "signature-value" - } - ] - }, - "report": { - "reportId": "report-abc", - "generatedAt": "2025-10-19T12:34:56Z", - "imageDigest": "sha256:feedface", - "policy": { - "digest": "digest-123", - "revisionId": "rev-42" - }, - "summary": { - "total": 1, - "blocked": 1, - "warned": 0, - "ignored": 0, - "quieted": 0 - }, - "verdict": "blocked", - "verdicts": [ - { - "findingId": "finding-1", - "status": "Blocked", - "score": 47.5, - "sourceTrust": "NVD", - "reachability": "runtime" - } - ], - "issues": [] - } - } -} + "dsse": { + "payloadType": "application/vnd.stellaops.report+json", + "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJyZWFjaGFiaWxpdHkiOiJydW50aW1lIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwic3RhdHVzIjoiQmxvY2tlZCJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHgranNvbjt2ZXJzaW9uPTEuNjt2aWV3PWludmVudG9yeSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoyNDU3NiwidmlldyI6ImludmVudG9yeSJ9LHsia2luZCI6InNib20tdXNhZ2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2ltYWdlcy9mZWVkZmFjZS9zYm9tLXVzYWdlLmNkeC5qc29uIiwiZGlnZXN0Ijoic2hhMjU2OjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIiLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuY3ljbG9uZWR4K2pzb247dmVyc2lvbj0xLjY7dmlldz11c2FnZSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoxNjM4NCwidmlldyI6InVzYWdlIn1dfX19", + "signatures": [ + { + "keyId": "test-key", + "algorithm": "hs256", + "signature": "signature-value" + } + ] + }, + "report": { + "reportId": "report-abc", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "verdict": "blocked", + "policy": { + "revisionId": "rev-42", + "digest": "digest-123" + }, + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 + }, + "verdicts": [ + { + "findingId": "finding-1", + "reachability": "runtime", + "score": 47.5, + "sourceTrust": "NVD", + "status": "Blocked" + } + ], + "issues": [], + "surface": { + "tenant": "tenant-alpha", + "generatedAt": "2025-10-19T12:34:56+00:00", + "manifestDigest": "sha256:4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/tenant-alpha/sha256/4f/ee/4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "tenant-alpha", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "artifacts": [ + { + "kind": "entry-trace", + "uri": "cas://scanner-artifacts/scanner/entry-trace/f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0/entry-trace.json", + "digest": "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + "mediaType": "application/json", + "format": "json", + "sizeBytes": 4096 + }, + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom.cdx.json", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=inventory", + "format": "cdx-json", + "sizeBytes": 24576, + "view": "inventory" + }, + { + "kind": "sbom-usage", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom-usage.cdx.json", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=usage", + "format": "cdx-json", + "sizeBytes": 16384, + "view": "usage" + } + ] + } + } + } + }, + "attributes": { + "policyDigest": "digest-123", + "policyRevisionId": "rev-42", + "reportId": "report-abc", + "verdict": "blocked" + } +} diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md index 76383de5..2184cf32 100644 --- a/docs/implplan/SPRINTS.md +++ b/docs/implplan/SPRINTS.md @@ -129,7 +129,8 @@ Follow the sprint files below in order. Update task status in both `SPRINTS` and > 2025-11-02: DOCS-SCANNER-BENCH-62-012 marked DONE (Docs Guild, Language Analyzer Guild) – Dart coverage section fleshed out with detection strategies. > 2025-11-02: DOCS-SCANNER-BENCH-62-013 marked DONE (Docs Guild, Swift Analyzer Guild) – Swift analyzer roadmap captured with policy hooks. > 2025-11-02: DOCS-SCANNER-BENCH-62-014 marked DONE (Docs Guild, Runtime Guild) – Kubernetes/VM alignment section published. -> 2025-11-02: DOCS-SCANNER-BENCH-62-015 marked DONE (Docs Guild, Export Center Guild) – DSSE/Rekor enablement guidance appended to gap doc. +> 2025-11-02: DOCS-SCANNER-BENCH-62-015 marked DONE (Docs Guild, Export Center Guild) – DSSE/Rekor enablement guidance appended to gap doc. +> 2025-11-05: SCANNER-SURFACE-02 marked DONE (Scanner WebService Guild) – WebService now persists `surface` manifest pointers in scan/report APIs, orchestrator samples and DSSE fixtures refreshed, and readiness tests updated with Surface validators stubbed for deterministic health checks. > 2025-11-02: SCANNER-ENG-0009 moved to DOING (Ruby Analyzer Guild) – drafting Ruby analyzer parity design package. > 2025-11-02: SCANNER-ENG-0016 added (Ruby Analyzer Guild) – implementing Ruby lock collector & vendor cache ingestion. > 2025-11-02: SCANNER-ENG-0016 moved to DOING (Ruby Analyzer Guild) – lockfile parser skeleton committed with initial Gemfile.lock parsing. diff --git a/docs/implplan/SPRINT_100_identity_signing.md b/docs/implplan/SPRINT_100_identity_signing.md index f7afed53..a05d59d7 100644 --- a/docs/implplan/SPRINT_100_identity_signing.md +++ b/docs/implplan/SPRINT_100_identity_signing.md @@ -105,10 +105,10 @@ SEC3.PLG | BLOCKED (2025-10-21) | Ensure lockout responses and rate-limit metada SEC5.PLG | BLOCKED (2025-10-21) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) PLG7.IMPL-001 | DONE (2025-11-03) | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | BE-Auth Plugin (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) > 2025-11-03: Initial `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration options + registrar; sample manifest (`etc/authority.plugins/ldap.yaml`) updated to new schema (client certificate, trust store, insecure toggle). -PLG7.IMPL-002 | DOING (2025-11-03) | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | BE-Auth Plugin, Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) +PLG7.IMPL-002 | DONE (2025-11-04) | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | BE-Auth Plugin, Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) > 2025-11-03: Review concluded; RFC accepted with audit/mTLS/mapping decisions recorded in `docs/notes/2025-11-03-authority-plugin-ldap-review.md`. Follow-up implementation tasks PLG7.IMPL-001..005 added to plugin board. > 2025-11-04: Updated connection factory to negotiate StartTLS via `StartTransportLayerSecurity(null)` and normalized LDAP result-code handling (invalid credentials + transient codes) against `System.DirectoryServices.Protocols` 8.0. Plugin unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj`) now passes again after the retry/error-path fixes. -> 2025-11-04: PLG7.IMPL-002 progress – enforced TLS/client certificate validation, expanded LDAP audit properties and retry telemetry, warned when cipher lists are unsupported, refreshed sample config, and reran `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`. +> 2025-11-04: PLG7.IMPL-002 DONE – deterministic credential store retries now emit metrics + structured audit context, DirectoryServices factory enforces TLS/mTLS settings (trust store + client cert), and configuration samples/docs refreshed. Tests: `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`. [Identity & Signing] 100.C) IssuerDirectory Summary: Identity & Signing focus on IssuerDirectory. diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index b1f67c0a..d3a54cf0 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -2,7 +2,7 @@ ## Status Snapshot (2025-11-04) -- **Advisory AI** – 5 of 11 tasks are DONE (AIAI-31-001, AIAI-31-002, AIAI-31-003, AIAI-31-010, AIAI-31-011); orchestration pipeline (AIAI-31-004) and host wiring (AIAI-31-004A) remain DOING while downstream guardrails, CLI, and observability tracks (AIAI-31-004B/004C and AIAI-31-005 through AIAI-31-009) stay TODO pending cache/guardrail implementation and WebService/Worker hardening. +- **Advisory AI** – 5 of 11 tasks are DONE (AIAI-31-001, AIAI-31-002, AIAI-31-003, AIAI-31-010, AIAI-31-011); orchestration pipeline (AIAI-31-004) and host wiring (AIAI-31-004A) remain TODO while downstream guardrails, CLI, and observability tracks (AIAI-31-004B/004C and AIAI-31-005 through AIAI-31-009) stay TODO pending cache/guardrail implementation and WebService/Worker hardening. - 2025-11-04: AIAI-31-002 and AIAI-31-003 shipped with deterministic SBOM context client wiring (`AddSbomContext` typed HTTP client) and toolset integration; WebService/Worker now invoke the orchestrator with SBOM-backed simulations and emit initial metrics. - 2025-11-03: AIAI-31-002 landed the configurable HTTP client + DI defaults; retriever now resolves data via `/v1/sbom/context`, retaining a null fallback until SBOM service ships. - 2025-11-03: Follow-up: SBOM guild to deliver base URL/API key and run an Advisory AI smoke retrieval once SBOM-AIAI-31-001 endpoints are live. @@ -25,8 +25,8 @@ Task ID | State | Task description | Owners (Source) AIAI-31-001 | DONE (2025-11-02) | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. Dependencies: CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) AIAI-31-002 | DONE (2025-11-04) | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). Dependencies: SBOM-VULN-29-001. | Advisory AI Guild, SBOM Service Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) AIAI-31-003 | DONE (2025-11-04) | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. Dependencies: AIAI-31-001..002. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) -AIAI-31-004 | DOING | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). Dependencies: AIAI-31-001..003, AUTH-VULN-29-001. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) -AIAI-31-004A | DOING (2025-11-04) | Wire orchestrator into WebService/Worker, expose API + queue contract, emit metrics, stub cache. Dependencies: AIAI-31-004, AIAI-31-002. | Advisory AI Guild, Platform Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) +AIAI-31-004 | DONE (2025-11-04) | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). Dependencies: AIAI-31-001..003, AUTH-VULN-29-001. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) +AIAI-31-004A | DONE (2025-11-04) | Wire orchestrator into WebService/Worker, expose API + queue contract, emit metrics, stub cache. Dependencies: AIAI-31-004, AIAI-31-002. | Advisory AI Guild, Platform Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) > 2025-11-03: WebService/Worker scaffolds created with in-memory cache/queue, minimal APIs (`/api/v1/advisory/plan`, `/api/v1/advisory/queue`), metrics counters, and plan cache instrumentation; worker processes queue using orchestrator. > 2025-11-04: SBOM base address now flows via `SbomContextClientOptions.BaseAddress`, worker emits queue/plan metrics, and orchestrator cache keys expanded to cover SBOM hash inputs. AIAI-31-004B | TODO | Implement prompt assembler, guardrails, cache persistence, DSSE provenance, golden outputs. Dependencies: AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004. | Advisory AI Guild, Security Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) @@ -41,6 +41,7 @@ DOCS-AIAI-31-007 | BLOCKED (2025-11-03) | Write `/docs/security/assistant-guardr DOCS-AIAI-31-008 | BLOCKED (2025-11-03) | Publish `/docs/sbom/remediation-heuristics.md` (feasibility scoring, blast radius). Dependencies: SBOM-AIAI-31-001. | Docs Guild, SBOM Service Guild (docs/TASKS.md) DOCS-AIAI-31-009 | BLOCKED (2025-11-03) | Create `/docs/runbooks/assistant-ops.md` for warmup, cache priming, model outages, scaling. Dependencies: DEVOPS-AIAI-31-001. | Docs Guild, DevOps Guild (docs/TASKS.md) > 2025-11-03: DOCS-AIAI-31-003 moved to DOING – drafting Advisory AI API reference (endpoints, rate limits, error model) for sprint 110. +> 2025-11-04: AIAI-31-005 DONE – guardrail pipeline redacts secrets, enforces citation/injection policies, emits block counters, and tests (`AdvisoryGuardrailPipelineTests`) cover redaction + citation validation. > 2025-11-03: DOCS-AIAI-31-003 marked DONE – `docs/advisory-ai/api.md` published with scopes, request/response schemas, rate limits, and error catalogue (Docs Guild). > 2025-11-03: DOCS-AIAI-31-001 marked DONE – `docs/advisory-ai/overview.md` published with value, personas, guardrails, observability, and roadmap checklists (Docs Guild). > 2025-11-03: DOCS-AIAI-31-002 marked DONE – `docs/advisory-ai/architecture.md` published describing pipeline, deterministic tooling, caching, and profile governance (Docs Guild). @@ -50,8 +51,8 @@ DOCS-AIAI-31-009 | BLOCKED (2025-11-03) | Create `/docs/runbooks/assistant-ops.m > 2025-11-03: DOCS-AIAI-31-007 marked BLOCKED – Guardrail implementation (AIAI-31-005) incomplete. > 2025-11-03: DOCS-AIAI-31-008 marked BLOCKED – Waiting on SBOM heuristics delivery (SBOM-AIAI-31-001). > 2025-11-03: DOCS-AIAI-31-009 marked BLOCKED – DevOps runbook inputs (DEVOPS-AIAI-31-001) outstanding. -AIAI-31-005 | DOING (2025-11-03) | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. Dependencies: AIAI-31-004. | Advisory AI Guild, Security Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) -AIAI-31-006 | DOING (2025-11-03) | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. Dependencies: AIAI-31-004..005. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) +AIAI-31-005 | DONE (2025-11-04) | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. Dependencies: AIAI-31-004. | Advisory AI Guild, Security Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) +AIAI-31-006 | DONE (2025-11-04) | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. Dependencies: AIAI-31-004..005. | Advisory AI Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) > 2025-11-03: Shipped `/api/v1/advisory/{task}` execution and `/api/v1/advisory/outputs/{cacheKey}` retrieval endpoints with guardrail integration, provenance hashes, and metrics (RBAC & rate limiting still pending Authority scope delivery). AIAI-31-007 | TODO | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. Dependencies: AIAI-31-004..006. | Advisory AI Guild, Observability Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) AIAI-31-008 | TODO | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. Dependencies: AIAI-31-006..007. | Advisory AI Guild, DevOps Guild (src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md) @@ -64,6 +65,7 @@ AIAI-31-009 | TODO | Develop unit/golden/property/perf tests, injection harness, > 2025-11-02: AIAI-31-004 kicked off orchestration pipeline design – establishing deterministic task sequence (summary/conflict/remediation) and cache key strategy. > 2025-11-02: AIAI-31-004 orchestration prerequisites documented in docs/modules/advisory-ai/orchestration-pipeline.md (tasks 004A/004B/004C). > 2025-11-02: AIAI-31-003 moved to DOING – beginning deterministic tooling (comparators, dependency analysis) while awaiting SBOM context client. Semantic & EVR comparators shipped; toolset interface published for orchestrator adoption. +> 2025-11-04: AIAI-31-004 DONE – orchestrator composes evidence (structured/vector/SBOM) with stable cache keys, metadata, and hashing; tests keep determinism enforced. > 2025-11-02: Structured + vector retrievers landed with deterministic CSAF/OSV/Markdown chunkers, deterministic hash embeddings, and unit coverage for sample advisories. > 2025-11-02: SBOM context request/result models finalized; retriever tests now validate environment-flag toggles and dependency-path dedupe. SBOM guild to wire real context service client. > 2025-11-04: AIAI-31-002 completed – `AddSbomContext` typed client registered in WebService/Worker, BaseAddress/tenant headers sourced from configuration, and retriever HTTP-mapping tests extended. @@ -194,12 +196,12 @@ FEEDCONN-CERTBUND-02-010 Version range provenance | BE-Conn-CERTBUND | **TODO (d FEEDCONN-CISCO-02-009 SemVer range provenance | BE-Conn-Cisco | **TODO (due 2025-10-21)** – Emit Cisco SemVer ranges into `advisory_observations.affected.versions[]` with provenance identifiers (`cisco:{productId}`) and deterministic comparison keys. Update mapper/tests for the Link-Not-Merge schema and replace legacy merge counter checks with observation/linkset validation. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md) FEEDCONN-ICSCISA-02-012 Version range provenance | BE-Conn-ICS-CISA | **DONE (2025-11-03)** – Promote existing firmware/semver data into `advisory_observations.affected.versions[]` entries with deterministic comparison keys and provenance identifiers (`ics-cisa:{advisoryId}:{product}`). Add regression coverage for mixed firmware strings and raise a Models ticket only when observation schema needs a new comparison helper.
2025-10-29: Follow `docs/dev/normalized-rule-recipes.md` §2 to build observation version entries and log failures without invoking the retired merge helpers.
2025-11-03: Completed – connector now normalizes semver ranges with provenance notes, RSS fallback content clears the AOC guard, and end-to-end Fetch/Parse/Map integration tests pass. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ics.Cisa/TASKS.md) FEEDCONN-KISA-02-008 Firmware range provenance | BE-Conn-KISA, Models | **DONE (2025-11-04)** – Define comparison helpers for Hangul-labelled firmware ranges (`XFU 1.0.1.0084 ~ 2.0.1.0034`) and map them into `advisory_observations.affected.versions[]` with provenance tags. Coordinate with Models only if a new comparison scheme is required, then update localisation notes and fixtures for the Link-Not-Merge schema.
2025-11-03: Analysis in progress – auditing existing mapper output/fixtures ahead of implementing firmware range normalization and provenance wiring.
2025-11-03: SemVer normalization helper wired through `KisaMapper` with provenance slugs + vendor extensions; integration tests updated and green, follow-up capture for additional Hangul exclusivity markers queued before completion.
2025-11-03: Extended connector tests to cover single-ended (`이상`, `초과`, `이하`, `미만`) and non-numeric phrases, verifying normalized rule types (`gt`, `gte`, `lt`, `lte`) and fallback behaviour; broader corpus review remains before transitioning to DONE.
2025-11-03: Captured the top 10 `detailDos.do?IDX=` pages into `seed-data/kisa/html/` via `scripts/kisa_capture_html.py`; JSON endpoint (`rssDetailData.do?IDX=…`) now returns error pages, so connector updates must parse the embedded HTML or secure authenticated API access before closing.
2025-11-04: Fetch + parse pipeline now consumes the HTML detail pages end to end (metadata persisted, DOM parser extracts vendor/product ranges); fixtures/tests operate on the HTML snapshots to guard normalized SemVer + vendor extension expectations and severity extraction. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/TASKS.md) -FEEDCONN-SHARED-STATE-003 Source state seeding helper | Tools Guild, BE-Conn-MSRC | **DOING (2025-10-19)** – Provide a reusable CLI/utility to seed `pendingDocuments`/`pendingMappings` for connectors (MSRC backfills require scripted CVRF + detail injection). Coordinate with MSRC team for expected JSON schema and handoff once prototype lands. Prereqs confirmed none (2025-10-19). | Tools (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md) +FEEDCONN-SHARED-STATE-003 Source state seeding helper | Tools Guild, BE-Conn-MSRC | **DONE (2025-11-04)** – Delivered `SourceStateSeeder` CLI + processor APIs, Mongo fixtures, and MSRC runbook updates. Seeds raw docs + cursor state deterministically; tests cover happy/path/idempotent flows (`dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.Common.Tests/...` – note: requires `libcrypto.so.1.1` when running Mongo2Go locally). | Tools (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md) FEEDMERGE-COORD-02-901 Connector deadline check-ins | BE-Merge | **TODO (due 2025-10-21)** – Confirm Cccs/Cisco version-provenance updates land, capture `LinksetVersionCoverage` dashboard snapshots (expect zero missing-range warnings), and update coordination docs with the results.
2025-10-29: Observation metrics now surface `version_entries_total`/`missing_version_entries_total`; include screenshots for both when closing this task. | FEEDMERGE-COORD-02-900 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) FEEDMERGE-COORD-02-902 ICS-CISA version comparison support | BE-Merge, Models | **TODO (due 2025-10-23)** – Review ICS-CISA sample advisories, validate reuse of existing comparison helpers, and pre-stage Models ticket template only if a new firmware comparator is required. Document the outcome and observation coverage logs in coordination docs + tracker files.
2025-10-29: `docs/dev/normalized-rule-recipes.md` (§2–§3) now covers observation entries; attach decision summary + log sample when handing off to Models. Dependencies: FEEDMERGE-COORD-02-901. | FEEDMERGE-COORD-02-900 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) FEEDMERGE-COORD-02-903 KISA firmware scheme review | BE-Merge, Models | **TODO (due 2025-10-24)** – Pair with KISA team on proposed firmware comparison helper (`kisa.build` or variant), ensure observation mapper alignment, and open Models ticket only if a new comparator is required. Log the final helper signature and observation coverage metrics in coordination docs + tracker files. Dependencies: FEEDMERGE-COORD-02-902. | FEEDMERGE-COORD-02-900 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) -Fixture validation sweep | QA | **DOING (2025-10-19)** – Prereqs confirmed none; continuing RHSA fixture regeneration and diff review alongside mapper provenance updates.
2025-10-29: Added `scripts/update-redhat-fixtures.sh` to regenerate golden snapshots with `UPDATE_GOLDENS=1`; run it before reviews to capture CSAF contract deltas. | None (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md) -Link-Not-Merge version provenance coordination | BE-Merge | **DOING** – Coordinate remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) so they emit `advisory_observations.affected.versions[]` entries with provenance tags and deterministic comparison keys. Track rollout status in `docs/dev/normalized-rule-recipes.md` (now updated for Link-Not-Merge) and retire the legacy merge counters as coverage transitions to linkset validation metrics.
2025-10-29: Added new guidance in the doc for recording observation version metadata and logging gaps via `LinksetVersionCoverage` warnings to replace prior `concelier.merge.normalized_rules*` alerts. Dependencies: CONCELIER-LNM-21-203. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) +Fixture validation sweep | QA | **DONE (2025-11-04)** – Regenerated RHSA CSAF goldens via `scripts/update-redhat-fixtures.sh` (sets `UPDATE_GOLDENS=1`) and re-ran connector tests `dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.Distro.RedHat.Tests/StellaOps.Concelier.Connector.Distro.RedHat.Tests.csproj --no-restore` to confirm snapshot parity. | None (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md) +Link-Not-Merge version provenance coordination | BE-Merge | **DONE (2025-11-04)** – Published connector status tracker + follow-up IDs in `docs/dev/normalized-rule-recipes.md`, enabled `Normalized version rules missing` diagnostics in Merge, and aligned dashboards on `LinksetVersionCoverage`. Remaining gaps (ACSC/CCCS/CERTBUND/Cisco/RU-BDU) documented as upstream data deficiencies awaiting feed updates. Dependencies: CONCELIER-LNM-21-203. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) MERGE-LNM-21-001 | DONE (2025-11-03) | Draft `no-merge` migration playbook, documenting backfill strategy, feature flag rollout, and rollback steps for legacy merge pipeline deprecation.
2025-11-03: Authored `docs/migration/no-merge.md` covering rollout phases, backfill/validation checklists, and rollback guidance; shared artefact owners. | BE-Merge, Architecture Guild (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) @@ -358,3 +360,7 @@ MIRROR-CRT-58-002 | TODO | Integrate with Export Center scheduling to automate m If all tasks are done - read next sprint section - SPRINT_120_policy_reasoning.md + +> 2025-11-04: AIAI-31-004A DONE – WebService/Worker wiring plus filesystem queue operational; metrics/logs added; tests executed via `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-restore`. + +> 2025-11-04: AIAI-31-006 DONE – REST endpoints enforce scope headers, apply rate limits, sanitize prompts through guardrails, and enqueue execution with cached metadata. diff --git a/docs/implplan/SPRINT_120_policy_reasoning.md b/docs/implplan/SPRINT_120_policy_reasoning.md index b7313131..1dbabf4f 100644 --- a/docs/implplan/SPRINT_120_policy_reasoning.md +++ b/docs/implplan/SPRINT_120_policy_reasoning.md @@ -21,7 +21,7 @@ Task ID | State | Task description | Owners (Source) LEDGER-29-001 | DONE (2025-11-03) | Design ledger & projection schemas (tables/indexes), canonical JSON format, hashing strategy, and migrations. Publish schema doc + fixtures.
2025-11-03: Initial migration, canonical fixtures, and schema doc alignment delivered (LEDGER-29-001). | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-002 | DONE (2025-11-03) | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.
2025-11-03: Web service + domain scaffolding landed with canonical hashing helpers, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests covering hashing & conflict flows. Dependencies: LEDGER-29-001. | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-003 | DONE (2025-11-03) | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`.
2025-11-03: Postgres projection services landed with replay checkpoints, fixtures, and unit coverage (LEDGER-29-003). Dependencies: LEDGER-29-002. | Findings Ledger Guild, Scheduler Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) -LEDGER-29-004 | DOING (2025-11-03) | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Reducer+worker now store `policy_rationale` via inline evaluation; Postgres schema/fixtures/tests updated, pending real Policy Engine client wiring. Dependencies: LEDGER-29-003. | Findings Ledger Guild, Policy Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) +LEDGER-29-004 | DONE (2025-11-04) | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Ledger service now calls `/api/policy/eval/batch` with resilient HttpClient, shared cache, and inline fallback; documentation/config samples updated; ledger tests executed (`dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore`). Dependencies: LEDGER-29-003. | Findings Ledger Guild, Policy Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-005 | TODO | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. Dependencies: LEDGER-29-004. | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-006 | TODO | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. Dependencies: LEDGER-29-005. | Findings Ledger Guild, Security Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-007 | TODO | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. Dependencies: LEDGER-29-006. | Findings Ledger Guild, Observability Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) diff --git a/docs/implplan/SPRINT_130_scanner_surface.md b/docs/implplan/SPRINT_130_scanner_surface.md index 64300237..0f2276ec 100644 --- a/docs/implplan/SPRINT_130_scanner_surface.md +++ b/docs/implplan/SPRINT_130_scanner_surface.md @@ -154,7 +154,7 @@ SCANNER-ENG-0025 | TODO | Implement WinSxS manifest collector per `design/window SCANNER-ENG-0026 | TODO | Implement Windows Chocolatey & registry collectors per `design/windows-analyzer.md` §3.3–3.4. | Scanner Guild (docs/modules/scanner/TASKS.md) SCANNER-ENG-0027 | TODO | Deliver Windows policy/offline integration per `design/windows-analyzer.md` §5–6. | Scanner Guild, Policy Guild, Offline Kit Guild (docs/modules/scanner/TASKS.md) SCANNER-SURFACE-01 | DOING (2025-11-02) | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Worker pipeline emitting draft Surface.FS manifests for sample scans; determinism checks running. | Scanner Worker Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) -SCANNER-SURFACE-02 | DOING (2025-11-02) | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata. Dependencies: SCANNER-SURFACE-01.
2025-11-02: WebService responses now include preview CAS URIs; attestation metadata updates staged for review. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) +SCANNER-SURFACE-02 | DONE (2025-11-05) | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata. Dependencies: SCANNER-SURFACE-01.
2025-11-05: Surface pointer projection wired through WebService endpoints, orchestrator samples & DSSE fixtures refreshed with `surface` manifest block, and regression suite (platform events, report sample, ready check) updated. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-SURFACE-03 | TODO | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. Dependencies: SCANNER-SURFACE-02. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md) [Scanner & Surface] 130.A) Scanner.VIII diff --git a/docs/modules/advisory-ai/architecture.md b/docs/modules/advisory-ai/architecture.md index 53d7dd64..529c3feb 100644 --- a/docs/modules/advisory-ai/architecture.md +++ b/docs/modules/advisory-ai/architecture.md @@ -1,139 +1,139 @@ -# Advisory AI architecture - -> Captures the retrieval, guardrail, and inference packaging requirements defined in the Advisory AI implementation plan and related module guides. - -## 1) Goals - -- Summarise advisories/VEX evidence into operator-ready briefs with citations. -- Explain conflicting statements with provenance and trust weights (using VEX Lens & Excititor data). -- Suggest remediation plans aligned with Offline Kit deployment models and scheduler follow-ups. -- Operate deterministically where possible; cache generated artefacts with digests for audit. - -## 2) Pipeline overview - -``` - +---------------------+ - Concelier/VEX Lens | Evidence Retriever | - Policy Engine ----> | (vector + keyword) | ---> Context Pack (JSON) - Zastava runtime +---------------------+ - | - v - +-------------+ - | Prompt | - | Assembler | - +-------------+ - | - v - +-------------+ - | Guarded LLM | - | (local/host)| - +-------------+ - | - v - +-----------------+ - | Citation & | - | Validation | - +-----------------+ - | - v - +----------------+ - | Output cache | - | (hash, bundle) | - +----------------+ -``` - -## 3) Retrieval & context - -- Hybrid search: vector embeddings (SBERT-compatible) + keyword filters for advisory IDs, PURLs, CVEs. -- Context packs include: - - Advisory raw excerpts with highlighted sections and source URLs. - - VEX statements (normalized tuples + trust metadata). - - Policy explain traces for the affected finding. - - Runtime/impact hints from Zastava (exposure, entrypoints). - - Export-ready remediation data (fixed versions, patches). -- **SBOM context retriever** (AIAI-31-002) hydrates: - - Version timelines (first/last observed, status, fix availability). - - Dependency paths (runtime vs build/test, deduped by coordinate chain). - - Tenant environment flags (prod/stage toggles) with optional blast radius summary. - - Service-side clamps: max 500 timeline entries, 200 dependency paths, with client-provided toggles for env/blast data. - - `AddSbomContextHttpClient(...)` registers the typed HTTP client that calls `/v1/sbom/context`, while `NullSbomContextClient` remains the safe default for environments that have not yet exposed the SBOM service. - - **Sample configuration** (wire real SBOM base URL + API key): - - ```csharp - services.AddSbomContextHttpClient(options => - { - options.BaseAddress = new Uri("https://sbom-service.internal"); - options.Endpoint = "/v1/sbom/context"; - options.ApiKey = configuration["SBOM_SERVICE_API_KEY"]; - options.UserAgent = "stellaops-advisoryai/1.0"; - options.Tenant = configuration["TENANT_ID"]; - }); - - services.AddAdvisoryPipeline(); - ``` - - After configuration, issue a smoke request (e.g., `ISbomContextRetriever.RetrieveAsync`) during deployment validation to confirm end-to-end connectivity and credentials before enabling Advisory AI endpoints. - -Retriever requests and results are trimmed/normalized before hashing; metadata (counts, provenance keys) is returned for downstream guardrails. Unit coverage ensures deterministic ordering and flag handling. - -All context references include `content_hash` and `source_id` enabling verifiable citations. - -## 4) Guardrails - -- Prompt templates enforce structure: summary, conflicts, remediation, references. -- Response validator ensures: - - No hallucinated advisories (every fact must map to input context). - - Citations follow `[n]` indexing referencing actual sources. - - Remediation suggestions only cite policy-approved sources (fixed versions, vendor hotfixes). -- Moderation/PII filters prevent leaking secrets; responses failing validation are rejected and logged. -- Pre-flight guardrails redact secrets (AWS keys, generic API tokens, PEM blobs), block "ignore previous instructions"-style prompt injection attempts, enforce citation presence, and cap prompt payload length (default 16 kB). Guardrail outcomes and redaction counts surface via `advisory_guardrail_blocks` / `advisory_outputs_stored` metrics. - -## 5) Deterministic tooling - -- **Version comparators** — offline semantic version + RPM EVR parsers with range evaluators. Supports chained constraints (`>=`, `<=`, `!=`) used by remediation advice and blast radius calcs. - - Registered via `AddAdvisoryDeterministicToolset` for reuse across orchestrator, CLI, and services. -- **Orchestration pipeline** — see `orchestration-pipeline.md` for prerequisites, task breakdown, and cross-guild responsibilities before wiring the execution flows. -- **Planned extensions** — NEVRA/EVR comparators, ecosystem-specific normalisers, dependency chain scorers (AIAI-31-003 scope). -- Exposed via internal interfaces to allow orchestrator/toolchain reuse; all helpers stay side-effect free and deterministic for golden testing. - -## 6) Output persistence - -- Cached artefacts stored in `advisory_ai_outputs` with fields: - - `output_hash` (sha256 of JSON response). - - `input_digest` (hash of context pack). - - `summary`, `conflicts`, `remediation`, `citations`. - - `generated_at`, `model_id`, `profile` (Sovereign/FIPS etc.). - - `signatures` (optional DSSE if run in deterministic mode). -- Offline bundle format contains `summary.md`, `citations.json`, `context_manifest.json`, `signatures/`. - -## 7) Profiles & sovereignty - -- **Profiles:** `default`, `fips-local` (FIPS-compliant local model), `gost-local`, `cloud-openai` (optional, disabled by default). Each profile defines allowed models, key management, and telemetry endpoints. -- **CryptoProfile/RootPack integration:** generated artefacts can be signed using configured CryptoProfile to satisfy procurement/trust requirements. - -## 8) APIs - -- `POST /api/v1/advisory/{task}` — executes Summary/Conflict/Remediation pipeline (`task` ∈ `summary|conflict|remediation`). Requests accept `{advisoryKey, artifactId?, policyVersion?, profile, preferredSections?, forceRefresh}` and return sanitized prompt payloads, citations, guardrail metadata, provenance hash, and cache hints. -- `GET /api/v1/advisory/outputs/{cacheKey}?taskType=SUMMARY&profile=default` — retrieves cached artefacts for downstream consumers (Console, CLI, Export Center). Guardrail state and provenance hash accompany results. - -All endpoints accept `profile` parameter (default `fips-local`) and return `output_hash`, `input_digest`, and `citations` for verification. - -## 9) Observability - -- Metrics: `advisory_ai_requests_total{profile,type}`, `advisory_ai_latency_seconds`, `advisory_ai_validation_failures_total`. -- Logs: include `output_hash`, `input_digest`, `profile`, `model_id`, `tenant`, `artifacts`. Sensitive context is not logged. -- Traces: spans for retrieval, prompt assembly, model inference, validation, cache write. - -## 10) Operational controls - -- Feature flags per tenant (`ai.summary.enabled`, `ai.remediation.enabled`). -- Rate limits (per tenant, per profile) enforced by Orchestrator to prevent runaway usage. -- Offline/air-gapped deployments run local models packaged with Offline Kit; model weights validated via manifest digests. - -## 11) Hosting surfaces - -- **WebService** — exposes `/v1/advisory-ai/pipeline/{task}` to materialise plans and enqueue execution messages. -- **Worker** — background service draining the advisory pipeline queue (file-backed stub) pending integration with shared transport. -- Both hosts register `AddAdvisoryAiCore`, which wires the SBOM context client, deterministic toolset, pipeline orchestrator, and queue metrics. -- SBOM base address + tenant metadata are configured via `AdvisoryAI:SbomBaseAddress` and propagated through `AddSbomContext`. +# Advisory AI architecture + +> Captures the retrieval, guardrail, and inference packaging requirements defined in the Advisory AI implementation plan and related module guides. + +## 1) Goals + +- Summarise advisories/VEX evidence into operator-ready briefs with citations. +- Explain conflicting statements with provenance and trust weights (using VEX Lens & Excititor data). +- Suggest remediation plans aligned with Offline Kit deployment models and scheduler follow-ups. +- Operate deterministically where possible; cache generated artefacts with digests for audit. + +## 2) Pipeline overview + +``` + +---------------------+ + Concelier/VEX Lens | Evidence Retriever | + Policy Engine ----> | (vector + keyword) | ---> Context Pack (JSON) + Zastava runtime +---------------------+ + | + v + +-------------+ + | Prompt | + | Assembler | + +-------------+ + | + v + +-------------+ + | Guarded LLM | + | (local/host)| + +-------------+ + | + v + +-----------------+ + | Citation & | + | Validation | + +-----------------+ + | + v + +----------------+ + | Output cache | + | (hash, bundle) | + +----------------+ +``` + +## 3) Retrieval & context + +- Hybrid search: vector embeddings (SBERT-compatible) + keyword filters for advisory IDs, PURLs, CVEs. +- Context packs include: + - Advisory raw excerpts with highlighted sections and source URLs. + - VEX statements (normalized tuples + trust metadata). + - Policy explain traces for the affected finding. + - Runtime/impact hints from Zastava (exposure, entrypoints). + - Export-ready remediation data (fixed versions, patches). +- **SBOM context retriever** (AIAI-31-002) hydrates: + - Version timelines (first/last observed, status, fix availability). + - Dependency paths (runtime vs build/test, deduped by coordinate chain). + - Tenant environment flags (prod/stage toggles) with optional blast radius summary. + - Service-side clamps: max 500 timeline entries, 200 dependency paths, with client-provided toggles for env/blast data. + - `AddSbomContextHttpClient(...)` registers the typed HTTP client that calls `/v1/sbom/context`, while `NullSbomContextClient` remains the safe default for environments that have not yet exposed the SBOM service. + + **Sample configuration** (wire real SBOM base URL + API key): + + ```csharp + services.AddSbomContextHttpClient(options => + { + options.BaseAddress = new Uri("https://sbom-service.internal"); + options.Endpoint = "/v1/sbom/context"; + options.ApiKey = configuration["SBOM_SERVICE_API_KEY"]; + options.UserAgent = "stellaops-advisoryai/1.0"; + options.Tenant = configuration["TENANT_ID"]; + }); + + services.AddAdvisoryPipeline(); + ``` + + After configuration, issue a smoke request (e.g., `ISbomContextRetriever.RetrieveAsync`) during deployment validation to confirm end-to-end connectivity and credentials before enabling Advisory AI endpoints. + +Retriever requests and results are trimmed/normalized before hashing; metadata (counts, provenance keys) is returned for downstream guardrails. Unit coverage ensures deterministic ordering and flag handling. + +All context references include `content_hash` and `source_id` enabling verifiable citations. + +## 4) Guardrails + +- Prompt templates enforce structure: summary, conflicts, remediation, references. +- Response validator ensures: + - No hallucinated advisories (every fact must map to input context). + - Citations follow `[n]` indexing referencing actual sources. + - Remediation suggestions only cite policy-approved sources (fixed versions, vendor hotfixes). +- Moderation/PII filters prevent leaking secrets; responses failing validation are rejected and logged. +- Pre-flight guardrails redact secrets (AWS keys, generic API tokens, PEM blobs), block "ignore previous instructions"-style prompt injection attempts, enforce citation presence, and cap prompt payload length (default 16 kB). Guardrail outcomes and redaction counts surface via `advisory_guardrail_blocks` / `advisory_outputs_stored` metrics. + +## 5) Deterministic tooling + +- **Version comparators** — offline semantic version + RPM EVR parsers with range evaluators. Supports chained constraints (`>=`, `<=`, `!=`) used by remediation advice and blast radius calcs. + - Registered via `AddAdvisoryDeterministicToolset` for reuse across orchestrator, CLI, and services. +- **Orchestration pipeline** — see `orchestration-pipeline.md` for prerequisites, task breakdown, and cross-guild responsibilities before wiring the execution flows. +- **Planned extensions** — NEVRA/EVR comparators, ecosystem-specific normalisers, dependency chain scorers (AIAI-31-003 scope). +- Exposed via internal interfaces to allow orchestrator/toolchain reuse; all helpers stay side-effect free and deterministic for golden testing. + +## 6) Output persistence + +- Cached artefacts stored in `advisory_ai_outputs` with fields: + - `output_hash` (sha256 of JSON response). + - `input_digest` (hash of context pack). + - `summary`, `conflicts`, `remediation`, `citations`. + - `generated_at`, `model_id`, `profile` (Sovereign/FIPS etc.). + - `signatures` (optional DSSE if run in deterministic mode). +- Offline bundle format contains `summary.md`, `citations.json`, `context_manifest.json`, `signatures/`. + +## 7) Profiles & sovereignty + +- **Profiles:** `default`, `fips-local` (FIPS-compliant local model), `gost-local`, `cloud-openai` (optional, disabled by default). Each profile defines allowed models, key management, and telemetry endpoints. +- **CryptoProfile/RootPack integration:** generated artefacts can be signed using configured CryptoProfile to satisfy procurement/trust requirements. + +## 8) APIs + +- `POST /api/v1/advisory/{task}` — executes Summary/Conflict/Remediation pipeline (`task` ∈ `summary|conflict|remediation`). Requests accept `{advisoryKey, artifactId?, policyVersion?, profile, preferredSections?, forceRefresh}` and return sanitized prompt payloads, citations, guardrail metadata, provenance hash, and cache hints. +- `GET /api/v1/advisory/outputs/{cacheKey}?taskType=SUMMARY&profile=default` — retrieves cached artefacts for downstream consumers (Console, CLI, Export Center). Guardrail state and provenance hash accompany results. + +All endpoints accept `profile` parameter (default `fips-local`) and return `output_hash`, `input_digest`, and `citations` for verification. + +## 9) Observability + +- Metrics: `advisory_ai_requests_total{profile,type}`, `advisory_ai_latency_seconds`, `advisory_ai_validation_failures_total`. +- Logs: include `output_hash`, `input_digest`, `profile`, `model_id`, `tenant`, `artifacts`. Sensitive context is not logged. +- Traces: spans for retrieval, prompt assembly, model inference, validation, cache write. + +## 10) Operational controls + +- Feature flags per tenant (`ai.summary.enabled`, `ai.remediation.enabled`). +- Rate limits (per tenant, per profile) enforced by Orchestrator to prevent runaway usage. +- Offline/air-gapped deployments run local models packaged with Offline Kit; model weights validated via manifest digests. + +## 11) Hosting surfaces + +- **WebService** — exposes `/v1/advisory-ai/pipeline/{task}` to materialise plans and enqueue execution messages. +- **Worker** — background service draining the advisory pipeline queue (file-backed stub) pending integration with shared transport. +- Both hosts register `AddAdvisoryAiCore`, which wires the SBOM context client, deterministic toolset, pipeline orchestrator, and queue metrics. +- SBOM base address + tenant metadata are configured via `AdvisoryAI:SbomBaseAddress` and propagated through `AddSbomContext`. diff --git a/docs/modules/advisory-ai/orchestration-pipeline.md b/docs/modules/advisory-ai/orchestration-pipeline.md index eea053c8..c91a00f3 100644 --- a/docs/modules/advisory-ai/orchestration-pipeline.md +++ b/docs/modules/advisory-ai/orchestration-pipeline.md @@ -1,96 +1,96 @@ -# Advisory AI Orchestration Pipeline (Planning Notes) - -> **Status:** In progress – orchestration metadata and cache-key wiring underway for AIAI-31-004. -> **Audience:** Advisory AI guild, WebService/Worker guilds, CLI guild, Docs/QA support teams. - -## 1. Goal - -Wire the deterministic pipeline (Summary / Conflict / Remediation flows) into the Advisory AI service, workers, and CLI with deterministic caching, prompt preparation, and guardrail fallback. This document captures the pre-integration checklist and task breakdown so each guild understands their responsibilities before coding begins. - -## 2. Prerequisites - -| Area | Requirement | Owner | Status | -|------|-------------|-------|--------| -| **Toolset** | Deterministic comparators, dependency analyzer (`IDeterministicToolset`, `AdvisoryPipelineOrchestrator`) | Advisory AI | ✅ landed (AIAI-31-003) | -| **SBOM context** | Real SBOM context client delivering timelines + dependency paths | SBOM Service Guild | ✅ typed client and DI helper ready; supply host BaseAddress at integration time | -| **Prompt artifacts** | Liquid/Handlebars prompt templates for summary/conflict/remediation | Advisory AI Docs Guild | ⏳ authoring needed | -| **Cache strategy** | Decision on DSSE or hash-only cache entries, TTLs, and eviction policy | Advisory AI + Platform | ⏳ hash-only plan keys implemented; persistence decision outstanding | -| **Auth scopes** | Confirm service account scopes for new API endpoints/worker-to-service calls | Authority Guild | 🔲 define | - -**Blocking risk:** SBOM client and prompt templates must exist (even stubbed) before the orchestrator can produce stable plans. - -## 3. Integration plan (high-level) - -1. **Service layer (WebService / Worker)** - - Inject `IAdvisoryPipelineOrchestrator` via `AddAdvisoryPipeline`. - - Define REST endpoint `POST /v1/advisories/{key}/pipeline/{task}` (task ∈ summary/conflict/remediation). - - Worker consumes queue messages (`advisory.pipeline.execute`) -> fetches plan -> executes prompt -> persists output & provenance. - - Add metrics: `advisory_pipeline_requests_total`, `advisory_pipeline_plan_cache_hits_total`, `advisory_pipeline_latency_seconds`. -2. **CLI** - - New command `stella advise run ` with flags for artifact id, profile, policy version, `--force-refresh`. - - Render JSON/Markdown outputs; handle caching hints (print cache key, status). -3. **Caching / storage** - - Choose storage (Mongo collection vs existing DSSE output store). - - Persist `AdvisoryTaskPlan` metadata + generated output keyed by cache key + policy version. - - Expose TTL/force-refresh semantics. -4. **Docs & QA** - - Publish API spec (`docs/advisory-ai/api.md`) + CLI docs. - - Add golden outputs for deterministic runs; property tests for cache key stability (unit coverage landed for cache hashing + option clamps). - -## 4. Task Breakdown - -### AIAI-31-004A (Service orchestration wiring) - -- **Scope:** WebService/Worker injection, REST/queue plumbing, metrics counters, basic cache stub. -- **Dependencies:** `AddAdvisoryPipeline`, SBOM client stub. -- **Exit:** API responds with plan metadata + queue message; worker logs execution attempt; metrics emitted. - -### AIAI-31-004B (Prompt assembly & cache persistence) - -- **Scope:** Implement prompt assembler, connect to guardrails, persist cache entries w/ DSSE metadata. -- **Dependencies:** Prompt templates, cache storage decision, guardrail interface. -- **Exit:** Deterministic outputs stored; force-refresh honoured; tests cover prompt assembly + caching. -> 2025-11-03: Prompt assembler now emits deterministic JSON payloads, guardrail pipeline wiring is stubbed for upcoming security hardening, and outputs persist with DSSE-ready provenance metadata plus golden test coverage. - -### AIAI-31-004C (CLI integration & docs) - -- **Scope:** CLI command + output renderer, docs updates, CLI tests (golden outputs). -- **Dependencies:** Service endpoints stable, caching semantics documented. -- **Exit:** CLI command produces deterministic output, docs updated, smoke tests recorded. - -### AIAI-31-006 (Service API surface) - -- **Scope:** Expose REST endpoints for summary/conflict/remediation execution plus cached output retrieval (`POST /api/v1/advisory/{task}`, `GET /api/v1/advisory/outputs/{cacheKey}`). Include guardrail execution, provenance hashing, metrics, and stubs for RBAC/rate limits. -- **Dependencies:** Guardrail enforcement (AIAI-31-005), Authority scope wiring (`advisory-ai:view` / `advisory-ai:operate`), Offline kit docs. -- **Exit:** Endpoints return sanitized prompts with citations, guardrail metadata, DSSE hash, and plan cache indicators; OpenAPI description updated; rate-limit hooks ready for Authority integration. -> 2025-11-03: Initial REST surface shipped – direct execution runs through guardrail pipeline, outputs persist with DSSE-ready provenance, metrics `advisory_outputs_stored`/`advisory_guardrail_blocks` emit, and cache retrieval endpoint exposes stored artefacts (RBAC/header enforcement pending scope delivery). - -### Supporting tasks (other guilds) - -- **AUTH-AIAI-31-004** – Update scopes and DSSE policy (Authority guild). -- **DOCS-AIAI-31-003** – Publish API documentation, CLI guide updates (Docs guild). -- **QA-AIAI-31-004** – Golden/properties/perf suite for pipeline (QA guild). - -## 5. Acceptance checklist (per task) - -| Item | Notes | -|------|-------| -| Cache key stability | `AdvisoryPipelineOrchestrator` hash must remain stable under re-run of identical inputs. | -| Metrics & logging | Request id, cache key, task type, profile, latency; guardrail results logged without sensitive prompt data. | -| Offline readiness | All prompt templates bundled with Offline Kit; CLI works in air-gapped mode with cached data. | -| Policy awareness | Plans encode policy version used; outputs reference policy digest for audit. | -| Testing | Unit tests (plan generation, cache keys, DI), integration (service endpoint, worker, CLI), deterministic golden outputs. | - -## 6. Next steps - -1. Finalize SBOM context client (AIAI-31-002) and prompt templates. -2. Create queue schema spec (`docs/modules/advisory-ai/queue-contracts.md`) if not already available. -3. Schedule cross-guild kickoff to agree on cache store & DSSE policy. - -## 7. Recent updates - -- 2025-11-04 — Orchestrator metadata now captures SBOM environment flags, blast-radius metrics, and dependency analysis details; cache-key normalization covers ordering. -- 2025-11-04 — Unit tests added for SBOM-absent requests, option-limit enforcement, and cache-key stability. -- 2025-11-04 — `AddSbomContext` DI helper enforces tenant header + base address wiring for downstream hosts. - -_Last updated: 2025-11-04_ +# Advisory AI Orchestration Pipeline (Planning Notes) + +> **Status:** In progress – orchestration metadata and cache-key wiring underway for AIAI-31-004. +> **Audience:** Advisory AI guild, WebService/Worker guilds, CLI guild, Docs/QA support teams. + +## 1. Goal + +Wire the deterministic pipeline (Summary / Conflict / Remediation flows) into the Advisory AI service, workers, and CLI with deterministic caching, prompt preparation, and guardrail fallback. This document captures the pre-integration checklist and task breakdown so each guild understands their responsibilities before coding begins. + +## 2. Prerequisites + +| Area | Requirement | Owner | Status | +|------|-------------|-------|--------| +| **Toolset** | Deterministic comparators, dependency analyzer (`IDeterministicToolset`, `AdvisoryPipelineOrchestrator`) | Advisory AI | ✅ landed (AIAI-31-003) | +| **SBOM context** | Real SBOM context client delivering timelines + dependency paths | SBOM Service Guild | ✅ typed client and DI helper ready; supply host BaseAddress at integration time | +| **Prompt artifacts** | Liquid/Handlebars prompt templates for summary/conflict/remediation | Advisory AI Docs Guild | ⏳ authoring needed | +| **Cache strategy** | Decision on DSSE or hash-only cache entries, TTLs, and eviction policy | Advisory AI + Platform | ⏳ hash-only plan keys implemented; persistence decision outstanding | +| **Auth scopes** | Confirm service account scopes for new API endpoints/worker-to-service calls | Authority Guild | 🔲 define | + +**Blocking risk:** SBOM client and prompt templates must exist (even stubbed) before the orchestrator can produce stable plans. + +## 3. Integration plan (high-level) + +1. **Service layer (WebService / Worker)** + - Inject `IAdvisoryPipelineOrchestrator` via `AddAdvisoryPipeline`. + - Define REST endpoint `POST /v1/advisories/{key}/pipeline/{task}` (task ∈ summary/conflict/remediation). + - Worker consumes queue messages (`advisory.pipeline.execute`) -> fetches plan -> executes prompt -> persists output & provenance. + - Add metrics: `advisory_pipeline_requests_total`, `advisory_pipeline_plan_cache_hits_total`, `advisory_pipeline_latency_seconds`. +2. **CLI** + - New command `stella advise run ` with flags for artifact id, profile, policy version, `--force-refresh`. + - Render JSON/Markdown outputs; handle caching hints (print cache key, status). +3. **Caching / storage** + - Choose storage (Mongo collection vs existing DSSE output store). + - Persist `AdvisoryTaskPlan` metadata + generated output keyed by cache key + policy version. + - Expose TTL/force-refresh semantics. +4. **Docs & QA** + - Publish API spec (`docs/advisory-ai/api.md`) + CLI docs. + - Add golden outputs for deterministic runs; property tests for cache key stability (unit coverage landed for cache hashing + option clamps). + +## 4. Task Breakdown + +### AIAI-31-004A (Service orchestration wiring) + +- **Scope:** WebService/Worker injection, REST/queue plumbing, metrics counters, basic cache stub. +- **Dependencies:** `AddAdvisoryPipeline`, SBOM client stub. +- **Exit:** API responds with plan metadata + queue message; worker logs execution attempt; metrics emitted. + +### AIAI-31-004B (Prompt assembly & cache persistence) + +- **Scope:** Implement prompt assembler, connect to guardrails, persist cache entries w/ DSSE metadata. +- **Dependencies:** Prompt templates, cache storage decision, guardrail interface. +- **Exit:** Deterministic outputs stored; force-refresh honoured; tests cover prompt assembly + caching. +> 2025-11-03: Prompt assembler now emits deterministic JSON payloads, guardrail pipeline wiring is stubbed for upcoming security hardening, and outputs persist with DSSE-ready provenance metadata plus golden test coverage. + +### AIAI-31-004C (CLI integration & docs) + +- **Scope:** CLI command + output renderer, docs updates, CLI tests (golden outputs). +- **Dependencies:** Service endpoints stable, caching semantics documented. +- **Exit:** CLI command produces deterministic output, docs updated, smoke tests recorded. + +### AIAI-31-006 (Service API surface) + +- **Scope:** Expose REST endpoints for summary/conflict/remediation execution plus cached output retrieval (`POST /api/v1/advisory/{task}`, `GET /api/v1/advisory/outputs/{cacheKey}`). Include guardrail execution, provenance hashing, metrics, and stubs for RBAC/rate limits. +- **Dependencies:** Guardrail enforcement (AIAI-31-005), Authority scope wiring (`advisory-ai:view` / `advisory-ai:operate`), Offline kit docs. +- **Exit:** Endpoints return sanitized prompts with citations, guardrail metadata, DSSE hash, and plan cache indicators; OpenAPI description updated; rate-limit hooks ready for Authority integration. +> 2025-11-03: Initial REST surface shipped – direct execution runs through guardrail pipeline, outputs persist with DSSE-ready provenance, metrics `advisory_outputs_stored`/`advisory_guardrail_blocks` emit, and cache retrieval endpoint exposes stored artefacts (RBAC/header enforcement pending scope delivery). + +### Supporting tasks (other guilds) + +- **AUTH-AIAI-31-004** – Update scopes and DSSE policy (Authority guild). +- **DOCS-AIAI-31-003** – Publish API documentation, CLI guide updates (Docs guild). +- **QA-AIAI-31-004** – Golden/properties/perf suite for pipeline (QA guild). + +## 5. Acceptance checklist (per task) + +| Item | Notes | +|------|-------| +| Cache key stability | `AdvisoryPipelineOrchestrator` hash must remain stable under re-run of identical inputs. | +| Metrics & logging | Request id, cache key, task type, profile, latency; guardrail results logged without sensitive prompt data. | +| Offline readiness | All prompt templates bundled with Offline Kit; CLI works in air-gapped mode with cached data. | +| Policy awareness | Plans encode policy version used; outputs reference policy digest for audit. | +| Testing | Unit tests (plan generation, cache keys, DI), integration (service endpoint, worker, CLI), deterministic golden outputs. | + +## 6. Next steps + +1. Finalize SBOM context client (AIAI-31-002) and prompt templates. +2. Create queue schema spec (`docs/modules/advisory-ai/queue-contracts.md`) if not already available. +3. Schedule cross-guild kickoff to agree on cache store & DSSE policy. + +## 7. Recent updates + +- 2025-11-04 — Orchestrator metadata now captures SBOM environment flags, blast-radius metrics, and dependency analysis details; cache-key normalization covers ordering. +- 2025-11-04 — Unit tests added for SBOM-absent requests, option-limit enforcement, and cache-key stability. +- 2025-11-04 — `AddSbomContext` DI helper enforces tenant header + base address wiring for downstream hosts. + +_Last updated: 2025-11-04_ diff --git a/docs/modules/cli/guides/cli-reference.md b/docs/modules/cli/guides/cli-reference.md index 6d4ba8e5..951e3f35 100644 --- a/docs/modules/cli/guides/cli-reference.md +++ b/docs/modules/cli/guides/cli-reference.md @@ -1,298 +1,298 @@ -# CLI AOC Commands Reference - -> **Audience:** DevEx engineers, operators, and CI authors integrating the `stella` CLI with Aggregation-Only Contract (AOC) workflows. -> **Scope:** Command synopsis, options, exit codes, and offline considerations for `stella sources ingest --dry-run` and `stella aoc verify` as introduced in Sprint 19. - +# CLI AOC Commands Reference + +> **Audience:** DevEx engineers, operators, and CI authors integrating the `stella` CLI with Aggregation-Only Contract (AOC) workflows. +> **Scope:** Command synopsis, options, exit codes, and offline considerations for `stella sources ingest --dry-run` and `stella aoc verify` as introduced in Sprint 19. + Both commands are designed to enforce the AOC guardrails documented in the [aggregation-only reference](../../../ingestion/aggregation-only-contract.md) and the [architecture overview](../architecture.md). They consume Authority-issued tokens with tenant scopes and never mutate ingestion stores. - ---- - -## 1 · Prerequisites - -- CLI version: `stella` ≥ 0.19.0 (AOC feature gate enabled). -- Required scopes (DPoP-bound): - - `advisory:read` for Concelier sources. - - `vex:read` for Excititor sources (optional but required for VEX checks). - - `aoc:verify` to invoke guard verification endpoints. - - `tenant:select` if your deployment uses tenant switching. -- Connectivity: direct access to Concelier/Excititor APIs or Offline Kit snapshot (see § 4). -- Environment: set `STELLA_AUTHORITY_URL`, `STELLA_TENANT`, and export a valid OpTok via `stella auth login` or existing token cache. - ---- - -## 2 · `stella sources ingest --dry-run` - -### 2.1 Synopsis - -```bash -stella sources ingest --dry-run \ - --source \ - --input \ - [--tenant ] \ - [--format json|table] \ - [--no-color] \ - [--output ] -``` - -### 2.2 Description - -Previews an ingestion write without touching MongoDB. The command loads an upstream advisory or VEX document, computes the would-write payload, runs it through the `AOCWriteGuard`, and reports any forbidden fields, provenance gaps, or idempotency issues. Use it during connector development, CI validation, or while triaging incidents. - -### 2.3 Options - -| Option | Description | -|--------|-------------| -| `--source ` | Logical source name (`redhat`, `ubuntu`, `osv`, etc.). Mirrors connector configuration. | -| `--input ` | Path to local CSAF/OSV/VEX file or HTTPS URI. CLI normalises transport (gzip/base64) before guard evaluation. | -| `--tenant ` | Overrides default tenant for multi-tenant deployments. Mandatory when `STELLA_TENANT` is not set. | -| `--format json|table` | Output format. `table` (default) prints summary with highlighted violations; `json` emits machine-readable report (see below). | -| `--no-color` | Disables ANSI colour output for CI logs. | -| `--output ` | Writes the JSON report to file while still printing human-readable summary to stdout. | - -### 2.4 Output schema (JSON) - -```json -{ - "source": "redhat", - "tenant": "default", - "guardVersion": "1.0.0", - "status": "ok", - "document": { - "contentHash": "sha256:…", - "supersedes": null, - "provenance": { - "signature": { "format": "pgp", "present": true } - } - }, - "violations": [] -} -``` - -When violations exist, `status` becomes `error` and `violations` contains entries with `code` (`ERR_AOC_00x`), a short `message`, and JSON Pointer `path` values indicating offending fields. - -### 2.5 Exit codes - -| Exit code | Meaning | -|-----------|---------| -| `0` | Guard passed; would-write payload is AOC compliant. | -| `11` | `ERR_AOC_001` – Forbidden field (`severity`, `cvss`, etc.) detected. | -| `12` | `ERR_AOC_002` – Merge attempt (multiple upstream sources fused). | -| `13` | `ERR_AOC_003` – Idempotency violation (duplicate without supersedes). | -| `14` | `ERR_AOC_004` – Missing provenance fields. | -| `15` | `ERR_AOC_005` – Signature/checksum mismatch. | -| `16` | `ERR_AOC_006` – Effective findings present (Policy-only data). | -| `17` | `ERR_AOC_007` – Unknown top-level fields / schema violation. | -| `70` | Transport error (network, auth, malformed input). | - -> Exit codes map directly to the `ERR_AOC_00x` table for scripting consistency. Multiple violations yield the highest-priority code (e.g., 11 takes precedence over 14). - -### 2.6 Examples - -Dry-run a local CSAF file: - -```bash -stella sources ingest --dry-run \ - --source redhat \ - --input ./fixtures/redhat/RHSA-2025-1234.json -``` - -Stream from HTTPS and emit JSON for CI: - -```bash -stella sources ingest --dry-run \ - --source osv \ - --input https://osv.dev/vulnerability/GHSA-aaaa-bbbb \ - --format json \ - --output artifacts/osv-dry-run.json - -cat artifacts/osv-dry-run.json | jq '.violations' -``` - -### 2.7 Offline notes - -When operating in sealed/offline mode: - -- Use `--input` paths pointing to Offline Kit snapshots (`offline-kit/advisories/*.json`). -- Provide `--tenant` explicitly if the offline bundle contains multiple tenants. -- The command does not attempt network access when given a file path. -- Store reports with `--output` to include in transfer packages for policy review. - ---- - -## 3 · `stella aoc verify` - -### 3.1 Synopsis - -```bash -stella aoc verify \ - [--since ] \ - [--limit ] \ - [--sources ] \ - [--codes ] \ - [--format table|json] \ - [--export ] \ - [--tenant ] \ - [--no-color] -``` - -### 3.2 Description - -Replays the AOC guard against stored raw documents. By default it checks all advisories and VEX statements ingested in the last 24 hours for the active tenant, reporting totals, top violation codes, and sample documents. Use it in CI pipelines, scheduled verifications, or during incident response. - -### 3.3 Options - -| Option | Description | -|--------|-------------| -| `--since ` | Verification window. Accepts ISO 8601 timestamp (`2025-10-25T12:00:00Z`) or duration (`48h`, `7d`). Defaults to `24h`. | -| `--limit ` | Maximum number of violations to display (per code). `0` means show all. Defaults to `20`. | -| `--sources ` | Comma-separated list of sources (`redhat,ubuntu,osv`). Filters both advisories and VEX entries. | -| `--codes ` | Restricts output to specific `ERR_AOC_00x` codes. Useful for regression tracking. | -| `--format table|json` | `table` (default) prints summary plus top violations; `json` outputs machine-readable report identical to the `/aoc/verify` API. | -| `--export ` | Writes the JSON report to disk (useful for audits/offline uploads). | -| `--tenant ` | Overrides tenant context. Required for cross-tenant verifications when run by platform operators. | -| `--no-color` | Disables ANSI colours. | - -`table` mode prints a summary showing the active tenant, evaluated window, counts of checked advisories/VEX statements, the active limit, total writes/violations, and whether the page was truncated. Status is colour-coded as `ok`, `violations`, or `truncated`. When violations exist the detail table lists the code, total occurrences, first sample document (`source` + `documentId` + `contentHash`), and JSON pointer path. - -### 3.4 Report structure (JSON) - -```json -{ - "tenant": "default", - "window": { - "from": "2025-10-25T12:00:00Z", - "to": "2025-10-26T12:00:00Z" - }, - "checked": { - "advisories": 482, - "vex": 75 - }, - "violations": [ - { - "code": "ERR_AOC_001", - "count": 2, - "examples": [ - { - "source": "redhat", - "documentId": "advisory_raw:redhat:RHSA-2025:1", - "contentHash": "sha256:…", - "path": "/content/raw/cvss" - } - ] - } - ], - "metrics": { - "ingestion_write_total": 557, - "aoc_violation_total": 2 - }, - "truncated": false -} -``` - -### 3.5 Exit codes - -| Exit code | Meaning | -|-----------|---------| -| `0` | Verification succeeded with zero violations. | -| `11…17` | Same mapping as § 2.5 when violations are detected. Highest-priority code returned. | -| `18` | Verification ran but results truncated (limit reached) – treat as warning; rerun with higher `--limit`. | -| `70` | Transport/authentication error. | -| `71` | CLI misconfiguration (missing tenant, invalid `--since`, etc.). | - -### 3.6 Examples - -Daily verification across all sources: - -```bash -stella aoc verify --since 24h --format table -``` - -CI pipeline focusing on errant sources and exporting evidence: - -```bash -stella aoc verify \ - --sources redhat,ubuntu \ - --codes ERR_AOC_001,ERR_AOC_004 \ - --format json \ - --limit 100 \ - --export artifacts/aoc-verify.json - -jq '.violations[] | {code, count}' artifacts/aoc-verify.json -``` - -Air-gapped verification using Offline Kit snapshot (example script): - -```bash -stella aoc verify \ - --since 7d \ - --format json \ - --export /mnt/offline/aoc-verify-$(date +%F).json - -sha256sum /mnt/offline/aoc-verify-*.json > /mnt/offline/checksums.txt -``` - -### 3.7 Automation tips - -- Schedule with `cron` or platform scheduler and fail the job when exit code ≥ 11. -- Pair with `stella sources ingest --dry-run` for pre-flight validation before re-enabling a paused source. -- Push JSON exports to observability pipelines for historical tracking of violation counts. - -### 3.8 Offline notes - -- Works against Offline Kit Mongo snapshots when CLI is pointed at the local API gateway included in the bundle. -- When fully disconnected, run against exported `aoc verify` reports generated on production and replay them using `--format json --export` (automation recipe above). -- Include verification output in compliance packages alongside Offline Kit manifests. - ---- - -## 4 · Global exit-code reference - -| Code | Summary | -|------|---------| -| `0` | Success / no violations. | -| `11` | `ERR_AOC_001` – Forbidden field present. | -| `12` | `ERR_AOC_002` – Merge attempt detected. | -| `13` | `ERR_AOC_003` – Idempotency violation. | -| `14` | `ERR_AOC_004` – Missing provenance/signature metadata. | -| `15` | `ERR_AOC_005` – Signature/checksum mismatch. | -| `16` | `ERR_AOC_006` – Effective findings in ingestion payload. | -| `17` | `ERR_AOC_007` – Schema violation / unknown fields. | -| `18` | Partial verification (limit reached). | -| `70` | Transport or HTTP failure. | -| `71` | CLI usage error (invalid arguments, missing tenant). | - -Use these codes in CI to map outcomes to build statuses or alert severities. - ---- - -## 4 · `stella vuln observations` (Overlay paging) - -`stella vuln observations` lists raw advisory observations for downstream overlays (Graph Explorer, Policy simulations, Console). Large tenants can now page through results deterministically. - -| Option | Description | -|--------|-------------| -| `--limit ` | Caps the number of observations returned in a single call. Defaults to `200`; values above `500` are clamped server-side. | -| `--cursor ` | Opaque continuation token produced by the previous page (`nextCursor` in JSON output). Pass it back to resume iteration. | - -Additional notes: - -- Table mode prints a hint when `hasMore` is `true`: - `[yellow]More observations available. Continue with --cursor [/]`. -- JSON mode returns `nextCursor` and `hasMore` alongside the observation list so automation can loop until `hasMore` is `false`. -- Supplying a non-positive limit falls back to the default (`200`). Invalid/expired cursors yield `400 Bad Request`; restart without `--cursor` to begin a fresh iteration. - ---- - -## 5 · Related references - + +--- + +## 1 · Prerequisites + +- CLI version: `stella` ≥ 0.19.0 (AOC feature gate enabled). +- Required scopes (DPoP-bound): + - `advisory:read` for Concelier sources. + - `vex:read` for Excititor sources (optional but required for VEX checks). + - `aoc:verify` to invoke guard verification endpoints. + - `tenant:select` if your deployment uses tenant switching. +- Connectivity: direct access to Concelier/Excititor APIs or Offline Kit snapshot (see § 4). +- Environment: set `STELLA_AUTHORITY_URL`, `STELLA_TENANT`, and export a valid OpTok via `stella auth login` or existing token cache. + +--- + +## 2 · `stella sources ingest --dry-run` + +### 2.1 Synopsis + +```bash +stella sources ingest --dry-run \ + --source \ + --input \ + [--tenant ] \ + [--format json|table] \ + [--no-color] \ + [--output ] +``` + +### 2.2 Description + +Previews an ingestion write without touching MongoDB. The command loads an upstream advisory or VEX document, computes the would-write payload, runs it through the `AOCWriteGuard`, and reports any forbidden fields, provenance gaps, or idempotency issues. Use it during connector development, CI validation, or while triaging incidents. + +### 2.3 Options + +| Option | Description | +|--------|-------------| +| `--source ` | Logical source name (`redhat`, `ubuntu`, `osv`, etc.). Mirrors connector configuration. | +| `--input ` | Path to local CSAF/OSV/VEX file or HTTPS URI. CLI normalises transport (gzip/base64) before guard evaluation. | +| `--tenant ` | Overrides default tenant for multi-tenant deployments. Mandatory when `STELLA_TENANT` is not set. | +| `--format json|table` | Output format. `table` (default) prints summary with highlighted violations; `json` emits machine-readable report (see below). | +| `--no-color` | Disables ANSI colour output for CI logs. | +| `--output ` | Writes the JSON report to file while still printing human-readable summary to stdout. | + +### 2.4 Output schema (JSON) + +```json +{ + "source": "redhat", + "tenant": "default", + "guardVersion": "1.0.0", + "status": "ok", + "document": { + "contentHash": "sha256:…", + "supersedes": null, + "provenance": { + "signature": { "format": "pgp", "present": true } + } + }, + "violations": [] +} +``` + +When violations exist, `status` becomes `error` and `violations` contains entries with `code` (`ERR_AOC_00x`), a short `message`, and JSON Pointer `path` values indicating offending fields. + +### 2.5 Exit codes + +| Exit code | Meaning | +|-----------|---------| +| `0` | Guard passed; would-write payload is AOC compliant. | +| `11` | `ERR_AOC_001` – Forbidden field (`severity`, `cvss`, etc.) detected. | +| `12` | `ERR_AOC_002` – Merge attempt (multiple upstream sources fused). | +| `13` | `ERR_AOC_003` – Idempotency violation (duplicate without supersedes). | +| `14` | `ERR_AOC_004` – Missing provenance fields. | +| `15` | `ERR_AOC_005` – Signature/checksum mismatch. | +| `16` | `ERR_AOC_006` – Effective findings present (Policy-only data). | +| `17` | `ERR_AOC_007` – Unknown top-level fields / schema violation. | +| `70` | Transport error (network, auth, malformed input). | + +> Exit codes map directly to the `ERR_AOC_00x` table for scripting consistency. Multiple violations yield the highest-priority code (e.g., 11 takes precedence over 14). + +### 2.6 Examples + +Dry-run a local CSAF file: + +```bash +stella sources ingest --dry-run \ + --source redhat \ + --input ./fixtures/redhat/RHSA-2025-1234.json +``` + +Stream from HTTPS and emit JSON for CI: + +```bash +stella sources ingest --dry-run \ + --source osv \ + --input https://osv.dev/vulnerability/GHSA-aaaa-bbbb \ + --format json \ + --output artifacts/osv-dry-run.json + +cat artifacts/osv-dry-run.json | jq '.violations' +``` + +### 2.7 Offline notes + +When operating in sealed/offline mode: + +- Use `--input` paths pointing to Offline Kit snapshots (`offline-kit/advisories/*.json`). +- Provide `--tenant` explicitly if the offline bundle contains multiple tenants. +- The command does not attempt network access when given a file path. +- Store reports with `--output` to include in transfer packages for policy review. + +--- + +## 3 · `stella aoc verify` + +### 3.1 Synopsis + +```bash +stella aoc verify \ + [--since ] \ + [--limit ] \ + [--sources ] \ + [--codes ] \ + [--format table|json] \ + [--export ] \ + [--tenant ] \ + [--no-color] +``` + +### 3.2 Description + +Replays the AOC guard against stored raw documents. By default it checks all advisories and VEX statements ingested in the last 24 hours for the active tenant, reporting totals, top violation codes, and sample documents. Use it in CI pipelines, scheduled verifications, or during incident response. + +### 3.3 Options + +| Option | Description | +|--------|-------------| +| `--since ` | Verification window. Accepts ISO 8601 timestamp (`2025-10-25T12:00:00Z`) or duration (`48h`, `7d`). Defaults to `24h`. | +| `--limit ` | Maximum number of violations to display (per code). `0` means show all. Defaults to `20`. | +| `--sources ` | Comma-separated list of sources (`redhat,ubuntu,osv`). Filters both advisories and VEX entries. | +| `--codes ` | Restricts output to specific `ERR_AOC_00x` codes. Useful for regression tracking. | +| `--format table|json` | `table` (default) prints summary plus top violations; `json` outputs machine-readable report identical to the `/aoc/verify` API. | +| `--export ` | Writes the JSON report to disk (useful for audits/offline uploads). | +| `--tenant ` | Overrides tenant context. Required for cross-tenant verifications when run by platform operators. | +| `--no-color` | Disables ANSI colours. | + +`table` mode prints a summary showing the active tenant, evaluated window, counts of checked advisories/VEX statements, the active limit, total writes/violations, and whether the page was truncated. Status is colour-coded as `ok`, `violations`, or `truncated`. When violations exist the detail table lists the code, total occurrences, first sample document (`source` + `documentId` + `contentHash`), and JSON pointer path. + +### 3.4 Report structure (JSON) + +```json +{ + "tenant": "default", + "window": { + "from": "2025-10-25T12:00:00Z", + "to": "2025-10-26T12:00:00Z" + }, + "checked": { + "advisories": 482, + "vex": 75 + }, + "violations": [ + { + "code": "ERR_AOC_001", + "count": 2, + "examples": [ + { + "source": "redhat", + "documentId": "advisory_raw:redhat:RHSA-2025:1", + "contentHash": "sha256:…", + "path": "/content/raw/cvss" + } + ] + } + ], + "metrics": { + "ingestion_write_total": 557, + "aoc_violation_total": 2 + }, + "truncated": false +} +``` + +### 3.5 Exit codes + +| Exit code | Meaning | +|-----------|---------| +| `0` | Verification succeeded with zero violations. | +| `11…17` | Same mapping as § 2.5 when violations are detected. Highest-priority code returned. | +| `18` | Verification ran but results truncated (limit reached) – treat as warning; rerun with higher `--limit`. | +| `70` | Transport/authentication error. | +| `71` | CLI misconfiguration (missing tenant, invalid `--since`, etc.). | + +### 3.6 Examples + +Daily verification across all sources: + +```bash +stella aoc verify --since 24h --format table +``` + +CI pipeline focusing on errant sources and exporting evidence: + +```bash +stella aoc verify \ + --sources redhat,ubuntu \ + --codes ERR_AOC_001,ERR_AOC_004 \ + --format json \ + --limit 100 \ + --export artifacts/aoc-verify.json + +jq '.violations[] | {code, count}' artifacts/aoc-verify.json +``` + +Air-gapped verification using Offline Kit snapshot (example script): + +```bash +stella aoc verify \ + --since 7d \ + --format json \ + --export /mnt/offline/aoc-verify-$(date +%F).json + +sha256sum /mnt/offline/aoc-verify-*.json > /mnt/offline/checksums.txt +``` + +### 3.7 Automation tips + +- Schedule with `cron` or platform scheduler and fail the job when exit code ≥ 11. +- Pair with `stella sources ingest --dry-run` for pre-flight validation before re-enabling a paused source. +- Push JSON exports to observability pipelines for historical tracking of violation counts. + +### 3.8 Offline notes + +- Works against Offline Kit Mongo snapshots when CLI is pointed at the local API gateway included in the bundle. +- When fully disconnected, run against exported `aoc verify` reports generated on production and replay them using `--format json --export` (automation recipe above). +- Include verification output in compliance packages alongside Offline Kit manifests. + +--- + +## 4 · Global exit-code reference + +| Code | Summary | +|------|---------| +| `0` | Success / no violations. | +| `11` | `ERR_AOC_001` – Forbidden field present. | +| `12` | `ERR_AOC_002` – Merge attempt detected. | +| `13` | `ERR_AOC_003` – Idempotency violation. | +| `14` | `ERR_AOC_004` – Missing provenance/signature metadata. | +| `15` | `ERR_AOC_005` – Signature/checksum mismatch. | +| `16` | `ERR_AOC_006` – Effective findings in ingestion payload. | +| `17` | `ERR_AOC_007` – Schema violation / unknown fields. | +| `18` | Partial verification (limit reached). | +| `70` | Transport or HTTP failure. | +| `71` | CLI usage error (invalid arguments, missing tenant). | + +Use these codes in CI to map outcomes to build statuses or alert severities. + +--- + +## 4 · `stella vuln observations` (Overlay paging) + +`stella vuln observations` lists raw advisory observations for downstream overlays (Graph Explorer, Policy simulations, Console). Large tenants can now page through results deterministically. + +| Option | Description | +|--------|-------------| +| `--limit ` | Caps the number of observations returned in a single call. Defaults to `200`; values above `500` are clamped server-side. | +| `--cursor ` | Opaque continuation token produced by the previous page (`nextCursor` in JSON output). Pass it back to resume iteration. | + +Additional notes: + +- Table mode prints a hint when `hasMore` is `true`: + `[yellow]More observations available. Continue with --cursor [/]`. +- JSON mode returns `nextCursor` and `hasMore` alongside the observation list so automation can loop until `hasMore` is `false`. +- Supplying a non-positive limit falls back to the default (`200`). Invalid/expired cursors yield `400 Bad Request`; restart without `--cursor` to begin a fresh iteration. + +--- + +## 5 · Related references + - [Aggregation-Only Contract reference](../../../ingestion/aggregation-only-contract.md) - [Architecture overview](../../platform/architecture-overview.md) - [Console AOC dashboard](../../../ui/console.md) - [Authority scopes](../../authority/architecture.md) - [Task Pack CLI profiles](./packs-profiles.md) - ---- - + +--- + ## 6 · Compliance checklist - [ ] Usage documented for both table and JSON formats. @@ -320,7 +320,7 @@ All publish/promote operations require interactive identities with `policy:publi *Last updated: 2025-11-03 (Sprint 100).* ## 13. Authority configuration quick reference - + | Setting | Purpose | How to set | |---------|---------|------------| | `StellaOps:Authority:OperatorReason` | Incident/change description recorded with `orch:operate` tokens. | CLI flag `--Authority:OperatorReason=...` or env `STELLAOPS_ORCH_REASON`. | @@ -332,4 +332,4 @@ All publish/promote operations require interactive identities with `policy:publi | `StellaOps:Authority:Scope` | Default scope string requested during `stella auth login`. | CLI flag `--Authority:Scope=\"packs.read packs.run\"` or env `STELLAOPS_AUTHORITY_SCOPE`; see `docs/modules/cli/guides/packs-profiles.md` for common Task Pack profiles. | > Tokens requesting `orch:operate` fail with `invalid_request` unless both operator values are present. `orch:quota` tokens require `quota_reason` (≤256 chars) and accept an optional `quota_ticket` (≤128 chars). `orch:backfill` tokens require both `backfill_reason` (≤256 chars) and `backfill_ticket` (≤128 chars). Avoid embedding secrets in any value. - + diff --git a/docs/modules/devops/AGENTS.md b/docs/modules/devops/AGENTS.md index 98bf555d..22c3745b 100644 --- a/docs/modules/devops/AGENTS.md +++ b/docs/modules/devops/AGENTS.md @@ -1,35 +1,35 @@ -# DevOps agent guide - -## Mission -The DevOps module captures release, deployment, and migration playbooks that keep StellaOps deterministic across environments. - -## Key docs -- [Module README](./README.md) -- [Architecture](./architecture.md) -- [Implementation plan](./implementation_plan.md) -- [Task board](./TASKS.md) -- [Task Runner simulation notes](./task-runner-simulation.md) - -## How to get started -1. Open ../../implplan/SPRINTS.md and locate the stories referencing this module. -2. Review ./TASKS.md for local follow-ups and confirm status transitions (TODO → DOING → DONE/BLOCKED). -3. Read the architecture and README for domain context before editing code or docs. -4. Coordinate cross-module changes in the main /AGENTS.md description and through the sprint plan. - -## Guardrails -- Honour the Aggregation-Only Contract where applicable (see ../../ingestion/aggregation-only-contract.md). -- Preserve determinism: sort outputs, normalise timestamps (UTC ISO-8601), and avoid machine-specific artefacts. -- Keep Offline Kit parity in mind—document air-gapped workflows for any new feature. -- Update runbooks/observability assets when operational characteristics change. -## Required Reading -- `docs/modules/devops/README.md` -- `docs/modules/devops/architecture.md` -- `docs/modules/devops/implementation_plan.md` -- `docs/modules/platform/architecture-overview.md` - -## Working Agreement -- 1. Update task status to `DOING`/`DONE` in both `docs/implplan/SPRINTS.md` and the local `TASKS.md` when you start or finish work. -- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met. -- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations. -- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change. -- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context. +# DevOps agent guide + +## Mission +The DevOps module captures release, deployment, and migration playbooks that keep StellaOps deterministic across environments. + +## Key docs +- [Module README](./README.md) +- [Architecture](./architecture.md) +- [Implementation plan](./implementation_plan.md) +- [Task board](./TASKS.md) +- [Task Runner simulation notes](./task-runner-simulation.md) + +## How to get started +1. Open ../../implplan/SPRINTS.md and locate the stories referencing this module. +2. Review ./TASKS.md for local follow-ups and confirm status transitions (TODO → DOING → DONE/BLOCKED). +3. Read the architecture and README for domain context before editing code or docs. +4. Coordinate cross-module changes in the main /AGENTS.md description and through the sprint plan. + +## Guardrails +- Honour the Aggregation-Only Contract where applicable (see ../../ingestion/aggregation-only-contract.md). +- Preserve determinism: sort outputs, normalise timestamps (UTC ISO-8601), and avoid machine-specific artefacts. +- Keep Offline Kit parity in mind—document air-gapped workflows for any new feature. +- Update runbooks/observability assets when operational characteristics change. +## Required Reading +- `docs/modules/devops/README.md` +- `docs/modules/devops/architecture.md` +- `docs/modules/devops/implementation_plan.md` +- `docs/modules/platform/architecture-overview.md` + +## Working Agreement +- 1. Update task status to `DOING`/`DONE` in both `docs/implplan/SPRINTS.md` and the local `TASKS.md` when you start or finish work. +- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met. +- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations. +- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change. +- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context. diff --git a/docs/modules/devops/README.md b/docs/modules/devops/README.md index 70263cec..a72b0918 100644 --- a/docs/modules/devops/README.md +++ b/docs/modules/devops/README.md @@ -1,42 +1,42 @@ -# StellaOps DevOps - -The DevOps module captures release, deployment, and migration playbooks that keep StellaOps deterministic across environments. - -## Responsibilities -- Maintain CI pipelines, signing workflows, and release packaging steps. -- Operate shared runbooks for launch readiness, upgrades, and NuGet previews. -- Provide offline kit assembly instructions and tooling integration. -- Wrap observability/telemetry bootstrap flows for platform teams. - -## Key components -- Runbooks under ./runbooks/ (launch, deployment, nuget). -- Migration guidance under ./migrations/. -- Architecture overview bridging CI/CD & infrastructure concerns. - -## Integrations & dependencies -- Ops pipelines (Gitea, GitHub Actions) and artifact registries. -- Authority/Signer for supply chain signing. -- Telemetry stack bootstrap scripts. - -## Operational notes -- Offline bundle packaging guidance in docs/modules/export-center/operations/runbook.md. -- Dashboards for launch cutover rehearsals. -- Coordination with Security for enforced guardrails. - -## Related resources -- ./runbooks/launch-readiness.md -- ./runbooks/launch-cutover.md -- ./runbooks/deployment-upgrade.md -- ./runbooks/nuget-preview-bootstrap.md -- ./migrations/semver-style.md -- ./task-runner-simulation.md - -## Backlog references -- DEVOPS-LAUNCH-18-001 / 18-900 runbooks in ../../TASKS.md. -- Telemetry bootstrap automation tracked in `ops/devops/TASKS.md`. - -## Epic alignment -- **Epic 1 – AOC enforcement:** bake AOC verifier steps, CI guards, and schema validation into pipelines. -- **Epic 9 – Orchestrator Dashboard:** support operational dashboards, job recovery runbooks, and rate-limit governance. -- **Epic 10 – Export Center:** manage signing workflows, Offline Kit packaging, and release promotion for exports. -- **Epic 15 – Observability & Forensics:** coordinate telemetry deployment, evidence retention, and forensic automation. +# StellaOps DevOps + +The DevOps module captures release, deployment, and migration playbooks that keep StellaOps deterministic across environments. + +## Responsibilities +- Maintain CI pipelines, signing workflows, and release packaging steps. +- Operate shared runbooks for launch readiness, upgrades, and NuGet previews. +- Provide offline kit assembly instructions and tooling integration. +- Wrap observability/telemetry bootstrap flows for platform teams. + +## Key components +- Runbooks under ./runbooks/ (launch, deployment, nuget). +- Migration guidance under ./migrations/. +- Architecture overview bridging CI/CD & infrastructure concerns. + +## Integrations & dependencies +- Ops pipelines (Gitea, GitHub Actions) and artifact registries. +- Authority/Signer for supply chain signing. +- Telemetry stack bootstrap scripts. + +## Operational notes +- Offline bundle packaging guidance in docs/modules/export-center/operations/runbook.md. +- Dashboards for launch cutover rehearsals. +- Coordination with Security for enforced guardrails. + +## Related resources +- ./runbooks/launch-readiness.md +- ./runbooks/launch-cutover.md +- ./runbooks/deployment-upgrade.md +- ./runbooks/nuget-preview-bootstrap.md +- ./migrations/semver-style.md +- ./task-runner-simulation.md + +## Backlog references +- DEVOPS-LAUNCH-18-001 / 18-900 runbooks in ../../TASKS.md. +- Telemetry bootstrap automation tracked in `ops/devops/TASKS.md`. + +## Epic alignment +- **Epic 1 – AOC enforcement:** bake AOC verifier steps, CI guards, and schema validation into pipelines. +- **Epic 9 – Orchestrator Dashboard:** support operational dashboards, job recovery runbooks, and rate-limit governance. +- **Epic 10 – Export Center:** manage signing workflows, Offline Kit packaging, and release promotion for exports. +- **Epic 15 – Observability & Forensics:** coordinate telemetry deployment, evidence retention, and forensic automation. diff --git a/docs/modules/platform/architecture-overview.md b/docs/modules/platform/architecture-overview.md index 8400274b..82c72b6a 100644 --- a/docs/modules/platform/architecture-overview.md +++ b/docs/modules/platform/architecture-overview.md @@ -1,139 +1,139 @@ -# StellaOps Architecture Overview (Sprint 19) - -> **Ownership:** Architecture Guild • Docs Guild -> **Audience:** Service owners, platform engineers, solution architects -> **Related:** [High-Level Architecture](../../07_HIGH_LEVEL_ARCHITECTURE.md), [Concelier Architecture](../concelier/architecture.md), [Policy Engine Architecture](../policy/architecture.md), [Aggregation-Only Contract](../../ingestion/aggregation-only-contract.md) - -This dossier summarises the end-to-end runtime topology after the Aggregation-Only Contract (AOC) rollout. It highlights where raw facts live, how ingest services enforce guardrails, and how downstream components consume those facts to derive policy decisions and user-facing experiences. - ---- - -## 1 · System landscape - -```mermaid -graph TD - subgraph Edge["Clients & Automation"] - CLI[stella CLI] - UI[Console SPA] - APIClients[CI / API Clients] - end - Gateway[API Gateway
(JWT + DPoP scopes)] - subgraph Scanner["Fact Collection"] - ScannerWeb[Scanner.WebService] - ScannerWorkers[Scanner.Workers] - Agent[Agent Runtime] - end - subgraph Ingestion["Aggregation-Only Ingestion (AOC)"] - Concelier[Concelier.WebService] - Excititor[Excititor.WebService] - RawStore[(MongoDB
advisory_raw / vex_raw)] - end - subgraph Derivation["Policy & Overlay"] - Policy[Policy Engine] - Scheduler[Scheduler Services] - Notify[Notifier] - end - subgraph Experience["UX & Export"] - UIService[Console Backend] - Exporters[Export / Offline Kit] - end - Observability[Telemetry Stack] - - CLI --> Gateway - UI --> Gateway - APIClients --> Gateway - Gateway --> ScannerWeb - ScannerWeb --> ScannerWorkers - ScannerWorkers --> Concelier - ScannerWorkers --> Excititor - Concelier --> RawStore - Excititor --> RawStore - RawStore --> Policy - Policy --> Scheduler - Policy --> Notify - Policy --> UIService - Scheduler --> UIService - UIService --> Exporters - Exporters --> CLI - Exporters --> Offline[Offline Kit] - Observability -.-> ScannerWeb - Observability -.-> Concelier - Observability -.-> Excititor - Observability -.-> Policy - Observability -.-> Scheduler - Observability -.-> Notify -``` - -Key boundaries: - -- **AOC border.** Everything inside the Ingestion subgraph writes only immutable raw facts plus link hints. Derived severity, consensus, and risk remain outside the border. -- **Policy-only derivation.** Policy Engine materialises `effective_finding_*` collections and emits overlays; other services consume but never mutate them. -- **Tenant enforcement.** Authority-issued DPoP scopes flow through Gateway to every service; raw stores and overlays include `tenant` strictly. - ---- - -## 2 · Aggregation-Only Contract focus - -### 2.1 Responsibilities at the boundary - -| Area | Services | Responsibilities under AOC | Forbidden under AOC | -|------|----------|-----------------------------|---------------------| -| **Ingestion (Concelier / Excititor)** | `StellaOps.Concelier.WebService`, `StellaOps.Excititor.WebService` | Fetch upstream advisories/VEX, verify signatures, compute linksets, append immutable documents to `advisory_raw` / `vex_raw`, emit observability signals, expose raw read APIs. | Computing severity, consensus, suppressions, or policy hints; merging upstream sources into a single derived record; mutating existing documents. | -| **Policy & Overlay** | `StellaOps.Policy.Engine`, Scheduler | Join SBOM inventory with raw advisories/VEX, evaluate policies, issue `effective_finding_*` overlays, drive remediation workflows. | Writing to raw collections; bypassing guard scopes; running without recorded provenance. | -| **Experience layers** | Console, CLI, Exporters | Surface raw facts + policy overlays; run `stella aoc verify`; render AOC dashboards and reports. | Accepting ingestion payloads that lack provenance or violate guard results. | - -### 2.2 Raw stores - -| Collection | Purpose | Key fields | Notes | -|------------|---------|------------|-------| -| `advisory_raw` | Immutable vendor/ecosystem advisory documents. | `_id`, `tenant`, `source.*`, `upstream.*`, `content.raw`, `linkset`, `supersedes`. | Idempotent by `(source.vendor, upstream.upstream_id, upstream.content_hash)`. | -| `vex_raw` | Immutable vendor VEX statements. | Mirrors `advisory_raw`; `identifiers.statements` summarises affected components. | Maintains supersedes chain identical to advisory flow. | -| Change streams (`advisory_raw_stream`, `vex_raw_stream`) | Feed Policy Engine and Scheduler. | `operationType`, `documentKey`, `fullDocument`, `tenant`, `traceId`. | Scope filtered per tenant before delivery. | - -### 2.3 Guarded ingestion sequence - -```mermaid -sequenceDiagram - participant Upstream as Upstream Source - participant Connector as Concelier/Excititor Connector - participant Guard as AOCWriteGuard - participant Mongo as MongoDB (advisory_raw / vex_raw) - participant Stream as Change Stream - participant Policy as Policy Engine - - Upstream-->>Connector: CSAF / OSV / VEX document - Connector->>Connector: Normalize transport, compute content_hash - Connector->>Guard: Candidate raw doc (source + upstream + content + linkset) - Guard-->>Connector: ERR_AOC_00x on violation - Guard->>Mongo: Append immutable document (with tenant & supersedes) - Mongo-->>Stream: Change event (tenant scoped) - Stream->>Policy: Raw delta payload - Policy->>Policy: Evaluate policies, compute effective findings -``` - ---- - -### 2.4 Authority scopes & tenancy - -| Scope | Holder | Purpose | Notes | -|-------|--------|---------|-------| -| `advisory:ingest` / `vex:ingest` | Concelier / Excititor collectors | Append raw documents through ingestion endpoints. | Paired with tenant claims; requests without tenant are rejected. | -| `advisory:read` / `vex:read` | DevOps verify identity, CLI | Run `stella aoc verify` or call `/aoc/verify`. | Read-only; cannot mutate raw docs. | -| `effective:write` | Policy Engine | Materialise `effective_finding_*` overlays. | Only Policy Engine identity may hold; ingestion contexts receive `ERR_AOC_006` if they attempt. | -| `findings:read` | Console, CLI, exports | Consume derived findings. | Enforced by Gateway and downstream services. | - ---- - -## 3 · Data & control flow highlights - -1. **Ingestion:** Concelier / Excititor connectors fetch upstream documents, compute linksets, and hand payloads to `AOCWriteGuard`. Guards validate schema, provenance, forbidden fields, supersedes pointers, and append-only rules before writing to Mongo. -2. **Verification:** `stella aoc verify` (CLI/CI) and `/aoc/verify` endpoints replay guard checks against stored documents, mapping `ERR_AOC_00x` codes to exit codes for automation. -3. **Policy evaluation:** Mongo change streams deliver tenant-scoped raw deltas. Policy Engine joins SBOM inventory (via BOM Index), executes deterministic policies, writes overlays, and emits events to Scheduler/Notify. -4. **Experience surfaces:** Console renders an AOC dashboard showing ingestion latency, guard violations, and supersedes depth. CLI exposes raw-document fetch helpers for auditing. Offline Kit bundles raw collections alongside guard configs to keep air-gapped installs verifiable. -5. **Observability:** All services emit `ingestion_write_total`, `aoc_violation_total{code}`, `ingestion_latency_seconds`, and trace spans `ingest.fetch`, `ingest.transform`, `ingest.write`, `aoc.guard`. Logs correlate via `traceId`, `tenant`, `source.vendor`, and `content_hash`. - ---- - +# StellaOps Architecture Overview (Sprint 19) + +> **Ownership:** Architecture Guild • Docs Guild +> **Audience:** Service owners, platform engineers, solution architects +> **Related:** [High-Level Architecture](../../07_HIGH_LEVEL_ARCHITECTURE.md), [Concelier Architecture](../concelier/architecture.md), [Policy Engine Architecture](../policy/architecture.md), [Aggregation-Only Contract](../../ingestion/aggregation-only-contract.md) + +This dossier summarises the end-to-end runtime topology after the Aggregation-Only Contract (AOC) rollout. It highlights where raw facts live, how ingest services enforce guardrails, and how downstream components consume those facts to derive policy decisions and user-facing experiences. + +--- + +## 1 · System landscape + +```mermaid +graph TD + subgraph Edge["Clients & Automation"] + CLI[stella CLI] + UI[Console SPA] + APIClients[CI / API Clients] + end + Gateway[API Gateway
(JWT + DPoP scopes)] + subgraph Scanner["Fact Collection"] + ScannerWeb[Scanner.WebService] + ScannerWorkers[Scanner.Workers] + Agent[Agent Runtime] + end + subgraph Ingestion["Aggregation-Only Ingestion (AOC)"] + Concelier[Concelier.WebService] + Excititor[Excititor.WebService] + RawStore[(MongoDB
advisory_raw / vex_raw)] + end + subgraph Derivation["Policy & Overlay"] + Policy[Policy Engine] + Scheduler[Scheduler Services] + Notify[Notifier] + end + subgraph Experience["UX & Export"] + UIService[Console Backend] + Exporters[Export / Offline Kit] + end + Observability[Telemetry Stack] + + CLI --> Gateway + UI --> Gateway + APIClients --> Gateway + Gateway --> ScannerWeb + ScannerWeb --> ScannerWorkers + ScannerWorkers --> Concelier + ScannerWorkers --> Excititor + Concelier --> RawStore + Excititor --> RawStore + RawStore --> Policy + Policy --> Scheduler + Policy --> Notify + Policy --> UIService + Scheduler --> UIService + UIService --> Exporters + Exporters --> CLI + Exporters --> Offline[Offline Kit] + Observability -.-> ScannerWeb + Observability -.-> Concelier + Observability -.-> Excititor + Observability -.-> Policy + Observability -.-> Scheduler + Observability -.-> Notify +``` + +Key boundaries: + +- **AOC border.** Everything inside the Ingestion subgraph writes only immutable raw facts plus link hints. Derived severity, consensus, and risk remain outside the border. +- **Policy-only derivation.** Policy Engine materialises `effective_finding_*` collections and emits overlays; other services consume but never mutate them. +- **Tenant enforcement.** Authority-issued DPoP scopes flow through Gateway to every service; raw stores and overlays include `tenant` strictly. + +--- + +## 2 · Aggregation-Only Contract focus + +### 2.1 Responsibilities at the boundary + +| Area | Services | Responsibilities under AOC | Forbidden under AOC | +|------|----------|-----------------------------|---------------------| +| **Ingestion (Concelier / Excititor)** | `StellaOps.Concelier.WebService`, `StellaOps.Excititor.WebService` | Fetch upstream advisories/VEX, verify signatures, compute linksets, append immutable documents to `advisory_raw` / `vex_raw`, emit observability signals, expose raw read APIs. | Computing severity, consensus, suppressions, or policy hints; merging upstream sources into a single derived record; mutating existing documents. | +| **Policy & Overlay** | `StellaOps.Policy.Engine`, Scheduler | Join SBOM inventory with raw advisories/VEX, evaluate policies, issue `effective_finding_*` overlays, drive remediation workflows. | Writing to raw collections; bypassing guard scopes; running without recorded provenance. | +| **Experience layers** | Console, CLI, Exporters | Surface raw facts + policy overlays; run `stella aoc verify`; render AOC dashboards and reports. | Accepting ingestion payloads that lack provenance or violate guard results. | + +### 2.2 Raw stores + +| Collection | Purpose | Key fields | Notes | +|------------|---------|------------|-------| +| `advisory_raw` | Immutable vendor/ecosystem advisory documents. | `_id`, `tenant`, `source.*`, `upstream.*`, `content.raw`, `linkset`, `supersedes`. | Idempotent by `(source.vendor, upstream.upstream_id, upstream.content_hash)`. | +| `vex_raw` | Immutable vendor VEX statements. | Mirrors `advisory_raw`; `identifiers.statements` summarises affected components. | Maintains supersedes chain identical to advisory flow. | +| Change streams (`advisory_raw_stream`, `vex_raw_stream`) | Feed Policy Engine and Scheduler. | `operationType`, `documentKey`, `fullDocument`, `tenant`, `traceId`. | Scope filtered per tenant before delivery. | + +### 2.3 Guarded ingestion sequence + +```mermaid +sequenceDiagram + participant Upstream as Upstream Source + participant Connector as Concelier/Excititor Connector + participant Guard as AOCWriteGuard + participant Mongo as MongoDB (advisory_raw / vex_raw) + participant Stream as Change Stream + participant Policy as Policy Engine + + Upstream-->>Connector: CSAF / OSV / VEX document + Connector->>Connector: Normalize transport, compute content_hash + Connector->>Guard: Candidate raw doc (source + upstream + content + linkset) + Guard-->>Connector: ERR_AOC_00x on violation + Guard->>Mongo: Append immutable document (with tenant & supersedes) + Mongo-->>Stream: Change event (tenant scoped) + Stream->>Policy: Raw delta payload + Policy->>Policy: Evaluate policies, compute effective findings +``` + +--- + +### 2.4 Authority scopes & tenancy + +| Scope | Holder | Purpose | Notes | +|-------|--------|---------|-------| +| `advisory:ingest` / `vex:ingest` | Concelier / Excititor collectors | Append raw documents through ingestion endpoints. | Paired with tenant claims; requests without tenant are rejected. | +| `advisory:read` / `vex:read` | DevOps verify identity, CLI | Run `stella aoc verify` or call `/aoc/verify`. | Read-only; cannot mutate raw docs. | +| `effective:write` | Policy Engine | Materialise `effective_finding_*` overlays. | Only Policy Engine identity may hold; ingestion contexts receive `ERR_AOC_006` if they attempt. | +| `findings:read` | Console, CLI, exports | Consume derived findings. | Enforced by Gateway and downstream services. | + +--- + +## 3 · Data & control flow highlights + +1. **Ingestion:** Concelier / Excititor connectors fetch upstream documents, compute linksets, and hand payloads to `AOCWriteGuard`. Guards validate schema, provenance, forbidden fields, supersedes pointers, and append-only rules before writing to Mongo. +2. **Verification:** `stella aoc verify` (CLI/CI) and `/aoc/verify` endpoints replay guard checks against stored documents, mapping `ERR_AOC_00x` codes to exit codes for automation. +3. **Policy evaluation:** Mongo change streams deliver tenant-scoped raw deltas. Policy Engine joins SBOM inventory (via BOM Index), executes deterministic policies, writes overlays, and emits events to Scheduler/Notify. +4. **Experience surfaces:** Console renders an AOC dashboard showing ingestion latency, guard violations, and supersedes depth. CLI exposes raw-document fetch helpers for auditing. Offline Kit bundles raw collections alongside guard configs to keep air-gapped installs verifiable. +5. **Observability:** All services emit `ingestion_write_total`, `aoc_violation_total{code}`, `ingestion_latency_seconds`, and trace spans `ingest.fetch`, `ingest.transform`, `ingest.write`, `aoc.guard`. Logs correlate via `traceId`, `tenant`, `source.vendor`, and `content_hash`. + +--- + ## 4 · Offline & disaster readiness - **Offline Kit:** Packages raw Mongo snapshots (`advisory_raw`, `vex_raw`) plus guard configuration and CLI verifier binaries so air-gapped sites can re-run AOC checks before promotion. @@ -177,10 +177,10 @@ sequenceDiagram - [ ] Mongo schema validators deployed for `advisory_raw` and `vex_raw`; change streams scoped per tenant. - [ ] Authority scopes (`advisory:*`, `vex:*`, `effective:*`) configured in Gateway and validated via integration tests. - [ ] `stella aoc verify` wired into CI/CD pipelines with seeded violation fixtures. -- [ ] Console AOC dashboard and CLI documentation reference the new ingestion contract. -- [ ] Offline Kit bundles include guard configs, verifier tooling, and documentation updates. -- [ ] Observability dashboards include violation, latency, and supersedes depth metrics with alert thresholds. - ---- - +- [ ] Console AOC dashboard and CLI documentation reference the new ingestion contract. +- [ ] Offline Kit bundles include guard configs, verifier tooling, and documentation updates. +- [ ] Observability dashboards include violation, latency, and supersedes depth metrics with alert thresholds. + +--- + *Last updated: 2025-11-03 (Replay planning refresh).* diff --git a/docs/modules/policy/architecture.md b/docs/modules/policy/architecture.md index 9b425c62..ea405bf2 100644 --- a/docs/modules/policy/architecture.md +++ b/docs/modules/policy/architecture.md @@ -179,9 +179,10 @@ Determinism guard instrumentation wraps the evaluator, rejecting access to forbi - **Queue:** Mongo queue with lease; each job assigned `leaseDuration`, `maxAttempts`. - **Workers:** Lease jobs, execute evaluation pipeline, report status (success/failure/canceled). Failures with recoverable errors requeue with backoff; determinism or schema violations mark job `failed` and raise incident event. - **Fairness:** Round-robin per `{tenant, policyId}`; emergency jobs (`priority=emergency`) jump queue but limited via circuit breaker. -- **Replay:** On demand, orchestrator rehydrates run via stored cursors and exports sealed bundle for audit/CI determinism checks. - ---- +- **Replay:** On demand, orchestrator rehydrates run via stored cursors and exports sealed bundle for audit/CI determinism checks. +- **Batch evaluation service (`/api/policy/eval/batch`):** Stateless evaluator powering Findings Ledger and replay/offline workflows. Requests contain canonical ledger events plus optional current projection; responses return status/severity/labels/rationale without mutating state. Policy Engine enforces per-tenant cost budgets, caches results by `(tenantId, policyVersion, eventHash, projectionHash)`, and falls back to inline evaluation when the remote service is disabled. + +--- ## 7 · Security & Tenancy diff --git a/docs/modules/scanner/design/surface-env.md b/docs/modules/scanner/design/surface-env.md index 74a4f3f6..747d8df0 100644 --- a/docs/modules/scanner/design/surface-env.md +++ b/docs/modules/scanner/design/surface-env.md @@ -112,7 +112,7 @@ Failures throw `SurfaceEnvironmentException` with error codes (`SURFACE_ENV_MISS ## 6. Integration Guidance - **Scanner Worker**: call `services.AddSurfaceEnvironment()` in `Program.cs` before registering analyzers. Pass `hostContext.Configuration.GetSection("Surface")` for overrides. -- **Scanner WebService**: build environment during startup, then expose selected values via diagnostics (`/internal/surface` when diagnostics enabled). +- **Scanner WebService**: build environment during startup using `AddSurfaceEnvironment`, `AddSurfaceValidation`, `AddSurfaceFileCache`, and `AddSurfaceSecrets`; readiness checks execute the validator runner and scan/report APIs emit Surface CAS pointers derived from the resolved configuration. - **Zastava Observer/Webhook**: use the same builder; ensure Helm charts set `ZASTAVA_` variables. - **Scheduler Planner (future)**: treat Surface.Env as read-only input; do not mutate settings. diff --git a/docs/modules/scanner/design/surface-fs.md b/docs/modules/scanner/design/surface-fs.md index e8df4a91..c4515bf2 100644 --- a/docs/modules/scanner/design/surface-fs.md +++ b/docs/modules/scanner/design/surface-fs.md @@ -66,6 +66,14 @@ Surface.FS exposes a gRPC/HTTP API consumed by .NET clients: .NET client wraps these calls and handles retries using Polly policies. +### WebService integration (2025-11-05) + +- `/api/v1/scans/{id}` and `/api/v1/reports` responses now include a `surface` block containing: + - `manifestUri` – `cas://` pointer to the Surface manifest JSON. + - `manifestDigest` – canonical SHA-256 over the manifest payload. + - `manifest.artifacts[]` – deterministic list with `kind`, `uri`, `digest`, `mediaType`, `format`, and optional `view`. URIs reuse the `ArtifactObjectKeyBuilder` semantics (`cas://{bucket}/{rootPrefix}/images/...`). +- This allows UI/CLI consumers to fetch manifests or artefacts without additional Surface.FS round-trips. + ## 4. Library Responsibilities Surface.FS library for .NET hosts provides: diff --git a/docs/modules/vuln-explorer/architecture.md b/docs/modules/vuln-explorer/architecture.md index 18446113..a123fdad 100644 --- a/docs/modules/vuln-explorer/architecture.md +++ b/docs/modules/vuln-explorer/architecture.md @@ -7,7 +7,7 @@ - See [`../findings-ledger/schema.md`](../findings-ledger/schema.md) for the canonical SQL schema, hashing strategy, and fixtures underpinning these collections. - **Collections / tables** - - `finding_records` – canonical, policy-derived findings enriched with advisory, VEX, SBOM, runtime context. Includes `policyVersion`, `advisoryRawIds`, `vexRawIds`, `sbomComponentId`, and `explainBundleRef`. + - `finding_records` – canonical, policy-derived findings enriched with advisory, VEX, SBOM, runtime context. Includes `policyVersion`, `advisoryRawIds`, `vexRawIds`, `sbomComponentId`, `policyRationale` (array of explain bundle URIs or remediation notes returned by `/api/policy/eval/batch`), and `explainBundleRef`. - `finding_history` – append-only state transitions (`new`, `triaged`, `accepted_risk`, `remediated`, `false_positive`, etc.) with timestamps, actor, and justification. - `triage_actions` – discrete operator actions (comment, assignment, remediation note, ticket link) with immutable provenance. - `remediation_plans` – structured remediation steps (affected assets, deadlines, recommended fixes, auto-generated from SRM/AI hints). @@ -17,7 +17,7 @@ ## 2) Triage workflow -1. **Ingest effective findings** from Policy Engine (stream `policy.finding.delta`). Each delta updates `finding_records`, generates history entries, and triggers notification rules. +1. **Ingest effective findings** from Policy Engine (stream `policy.finding.delta`) and on-demand batch evaluations. Projection workers call `/api/policy/eval/batch` with ledger event payloads to receive status/severity/label/rationale updates before writing `finding_records`. Each delta updates `finding_records`, generates history entries, and triggers notification rules. 2. **Prioritisation** uses contextual heuristics: runtime exposure, VEX status, policy severity, AI hints. Stored as `priorityScore` with provenance from Zastava/AI modules. 3. **Assignment & collaboration** – Operators claim findings, add comments, attach evidence, and link tickets. Assignment uses Authority identities and RBAC. 4. **Remediation tracking** – Link remediation plans, record progress, and integrate with Scheduler for follow-up scans once fixes deploy. diff --git a/docs/notifications/overview.md b/docs/notifications/overview.md index 35f6b49e..77e3cd33 100644 --- a/docs/notifications/overview.md +++ b/docs/notifications/overview.md @@ -1,78 +1,78 @@ -# Notifications Overview - -> **Imposed rule:** Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. - -Notifications Studio turns raw platform events into concise, tenant-scoped alerts that reach the right responders without overwhelming them. The service is sovereign/offline-first, follows the Aggregation-Only Contract (AOC), and produces deterministic outputs so the same configuration yields identical deliveries across environments. - ---- - -## 1. Mission & value - -- **Reduce noise.** Only materially new or high-impact changes reach chat, email, or webhooks thanks to rule filters, throttles, and digest windows. -- **Explainable results.** Every delivery is traceable back to a rule, action, and event payload stored in the delivery ledger; operators can audit what fired and why. -- **Safe by default.** Secrets remain in external stores, templates are sandboxed, quiet hours and throttles prevent storms, and idempotency guarantees protect downstream systems. -- **Offline-aligned.** All configuration, templates, and plug-ins ship with Offline Kits; no external SaaS is required to send notifications. - ---- - -## 2. Core capabilities - -| Capability | What it does | Key docs | -|------------|--------------|----------| -| Rules engine | Declarative matchers for event kinds, severities, namespaces, VEX context, KEV flags, and more. | [`notifications/rules.md`](rules.md) | -| Channel catalog | Slack, Teams, Email, Webhook connectors loaded via restart-time plug-ins; metadata stored without secrets. | [`notifications/architecture.md`](architecture.md) | +# Notifications Overview + +> **Imposed rule:** Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. + +Notifications Studio turns raw platform events into concise, tenant-scoped alerts that reach the right responders without overwhelming them. The service is sovereign/offline-first, follows the Aggregation-Only Contract (AOC), and produces deterministic outputs so the same configuration yields identical deliveries across environments. + +--- + +## 1. Mission & value + +- **Reduce noise.** Only materially new or high-impact changes reach chat, email, or webhooks thanks to rule filters, throttles, and digest windows. +- **Explainable results.** Every delivery is traceable back to a rule, action, and event payload stored in the delivery ledger; operators can audit what fired and why. +- **Safe by default.** Secrets remain in external stores, templates are sandboxed, quiet hours and throttles prevent storms, and idempotency guarantees protect downstream systems. +- **Offline-aligned.** All configuration, templates, and plug-ins ship with Offline Kits; no external SaaS is required to send notifications. + +--- + +## 2. Core capabilities + +| Capability | What it does | Key docs | +|------------|--------------|----------| +| Rules engine | Declarative matchers for event kinds, severities, namespaces, VEX context, KEV flags, and more. | [`notifications/rules.md`](rules.md) | +| Channel catalog | Slack, Teams, Email, Webhook connectors loaded via restart-time plug-ins; metadata stored without secrets. | [`notifications/architecture.md`](architecture.md) | | Templates | Locale-aware, deterministic rendering via safe helpers; channel defaults plus tenant-specific overrides. | [`notifications/templates.md`](templates.md) | | Digests | Coalesce bursts into periodic summaries with deterministic IDs and audit trails. | [`notifications/digests.md`](digests.md) | | Delivery ledger | Tracks rendered payload hashes, attempts, throttles, and outcomes for every action. | [`modules/notify/architecture.md`](../modules/notify/architecture.md#7-data-model-mongo) | | Ack tokens | DSSE-signed acknowledgement tokens with webhook allowlists and escalation guardrails enforced by Authority. | [`modules/notify/architecture.md`](../modules/notify/architecture.md#81-ack-tokens--escalation-workflows) | - ---- - -## 3. How it fits into Stella Ops - -1. **Producers emit events.** Scanner, Scheduler, VEX Lens, Attestor, and Zastava publish canonical envelopes (`NotifyEvent`) onto the internal bus. -2. **Notify.Worker evaluates rules.** For each tenant, the worker applies match filters, VEX gates, throttles, and digest policies before rendering the action. -3. **Connectors deliver.** Channel plug-ins send the rendered payload to Slack/Teams/Email/Webhook targets and report back attempts and outcomes. -4. **Consumers investigate.** Operators pivot from message links into Console dashboards, SBOM views, or policy overlays with correlation IDs preserved. - + +--- + +## 3. How it fits into Stella Ops + +1. **Producers emit events.** Scanner, Scheduler, VEX Lens, Attestor, and Zastava publish canonical envelopes (`NotifyEvent`) onto the internal bus. +2. **Notify.Worker evaluates rules.** For each tenant, the worker applies match filters, VEX gates, throttles, and digest policies before rendering the action. +3. **Connectors deliver.** Channel plug-ins send the rendered payload to Slack/Teams/Email/Webhook targets and report back attempts and outcomes. +4. **Consumers investigate.** Operators pivot from message links into Console dashboards, SBOM views, or policy overlays with correlation IDs preserved. + The Notify WebService fronts worker state with REST APIs used by the UI and CLI. Tenants authenticate via StellaOps Authority scopes `notify.viewer`, `notify.operator`, and (for escalated actions) `notify.admin`. All operations require the tenant header (`X-StellaOps-Tenant`) to preserve sovereignty boundaries. - ---- - -## 4. Operating model - -| Area | Guidance | -|------|----------| + +--- + +## 4. Operating model + +| Area | Guidance | +|------|----------| | **Tenancy** | Each rule, channel, template, and delivery belongs to exactly one tenant. Cross-tenant sharing is intentionally unsupported. | | **Determinism** | Configuration persistence normalises strings and sorts collections. Template rendering produces identical `bodyHash` values when inputs match. | | **Scaling** | Workers scale horizontally; per-tenant rule snapshots are cached and refreshed from Mongo change streams. Redis (or equivalent) guards throttles and locks. | | **Offline** | Offline Kits include plug-ins, default templates, and seed rules. Operators can edit YAML/JSON manifests before air-gapped deployment. | | **Security** | Channel secrets use indirection (`secretRef`), Authority-protected OAuth clients secure API access, and delivery payloads are redacted before storage where required. | | **Module boundaries** | 2025-11-02 decision: keep `src/Notify/` as the shared notification toolkit and `src/Notifier/` as the Notifications Studio runtime host until a packaging RFC covers the implications of merging. | - ---- - -## 5. Getting started (first 30 minutes) - -| Step | Goal | Reference | -|------|------|-----------| -| 1 | Deploy Notify WebService + Worker with Mongo and Redis | [`modules/notify/architecture.md`](../modules/notify/architecture.md#1-runtime-shape--projects) | + +--- + +## 5. Getting started (first 30 minutes) + +| Step | Goal | Reference | +|------|------|-----------| +| 1 | Deploy Notify WebService + Worker with Mongo and Redis | [`modules/notify/architecture.md`](../modules/notify/architecture.md#1-runtime-shape--projects) | | 2 | Register OAuth clients/scopes in Authority | [`etc/authority.yaml.sample`](../../etc/authority.yaml.sample) | -| 3 | Install channel plug-ins and capture secret references | [`plugins/notify`](../../plugins) | -| 4 | Create a tenant rule and test preview | [`POST /channels/{id}/test`](../modules/notify/architecture.md#8-external-apis-webservice) | -| 5 | Inspect deliveries and digests | `/api/v1/notify/deliveries`, `/api/v1/notify/digests` | - ---- - -## 6. Alignment with implementation work - -| Backlog item | Impact on docs | Status | -|--------------|----------------|--------| -| `NOTIFY-SVC-38-001..004` | Foundational correlation, throttling, simulation hooks. | **In progress** – align behaviour once services publish beta APIs. | -| `NOTIFY-SVC-39-001..004` | Adds correlation engine, digest generator, simulation API, quiet hours. | **Pending** – revisit rule/digest sections when these tasks merge. | - -Action: coordinate with the Notifications Service Guild when `NOTIFY-SVC-39-001..004` land to validate payload fields, quiet-hours semantics, and any new connector metadata that should be documented here and in the channel-specific guides. - ---- - -> **Imposed rule reminder:** Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. +| 3 | Install channel plug-ins and capture secret references | [`plugins/notify`](../../plugins) | +| 4 | Create a tenant rule and test preview | [`POST /channels/{id}/test`](../modules/notify/architecture.md#8-external-apis-webservice) | +| 5 | Inspect deliveries and digests | `/api/v1/notify/deliveries`, `/api/v1/notify/digests` | + +--- + +## 6. Alignment with implementation work + +| Backlog item | Impact on docs | Status | +|--------------|----------------|--------| +| `NOTIFY-SVC-38-001..004` | Foundational correlation, throttling, simulation hooks. | **In progress** – align behaviour once services publish beta APIs. | +| `NOTIFY-SVC-39-001..004` | Adds correlation engine, digest generator, simulation API, quiet hours. | **Pending** – revisit rule/digest sections when these tasks merge. | + +Action: coordinate with the Notifications Service Guild when `NOTIFY-SVC-39-001..004` land to validate payload fields, quiet-hours semantics, and any new connector metadata that should be documented here and in the channel-specific guides. + +--- + +> **Imposed rule reminder:** Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. diff --git a/docs/replay/DETERMINISTIC_REPLAY.md b/docs/replay/DETERMINISTIC_REPLAY.md index 376b7d1b..b2538fef 100644 --- a/docs/replay/DETERMINISTIC_REPLAY.md +++ b/docs/replay/DETERMINISTIC_REPLAY.md @@ -1,423 +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. - ---- +# 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 index ee973e61..a691a275 100644 --- a/docs/replay/DEVS_GUIDE_REPLAY.md +++ b/docs/replay/DEVS_GUIDE_REPLAY.md @@ -1,113 +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. - ---- +# 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/etc/findings-ledger.yaml b/etc/findings-ledger.yaml index 4d99a6ff..b0c17783 100644 --- a/etc/findings-ledger.yaml +++ b/etc/findings-ledger.yaml @@ -3,3 +3,25 @@ findings: ledger: database: connectionString: Host=postgres + authority: + issuer: https://authority.internal/ + requireHttpsMetadata: true + metadataAddress: https://authority.internal/.well-known/openid-configuration + audiences: + - stellaops:vuln-ledger + requiredScopes: + - vuln:operate + - vuln:audit + merkle: + batchSize: 1000 + windowDuration: 00:15:00 + projection: + batchSize: 200 + idleDelay: 00:00:05 + policyEngine: + baseAddress: https://policy-engine.svc.cluster.local/api/ + tenantHeaderName: X-Stella-Tenant + requestTimeout: 00:00:10 + cache: + sizeLimit: 2048 + entryLifetime: 00:30:00 diff --git a/out/analyzers/python/StellaOps.Auth.Abstractions.xml b/out/analyzers/python/StellaOps.Auth.Abstractions.xml index 50110445..abe1bd26 100644 --- a/out/analyzers/python/StellaOps.Auth.Abstractions.xml +++ b/out/analyzers/python/StellaOps.Auth.Abstractions.xml @@ -1,767 +1,767 @@ - - - - StellaOps.Auth.Abstractions - - - - - Canonical telemetry metadata for the StellaOps Authority stack. - - - - - service.name resource attribute recorded by Authority components. - - - - - service.namespace resource attribute aligning Authority with other StellaOps services. - - - - - Activity source identifier used by Authority instrumentation. - - - - - Meter name used by Authority instrumentation. - - - - - Builds the default set of resource attributes (service name/namespace/version). - - Optional assembly used to resolve the service version. - - - - Resolves the service version string from the provided assembly (defaults to the Authority telemetry assembly). - - - - - Represents an IP network expressed in CIDR notation. - - - - - Initialises a new . - - Canonical network address with host bits zeroed. - Prefix length (0-32 for IPv4, 0-128 for IPv6). - - - - Canonical network address with host bits zeroed. - - - - - Prefix length. - - - - - Attempts to parse the supplied value as CIDR notation or a single IP address. - - Thrown when the input is not recognised. - - - - Attempts to parse the supplied value as CIDR notation or a single IP address. - - - - - Determines whether the provided address belongs to this network. - - - - - - - - Evaluates remote addresses against configured network masks. - - - - - Creates a matcher from raw CIDR strings. - - Sequence of CIDR entries or IP addresses. - Thrown when a value cannot be parsed. - - - - Creates a matcher from already parsed masks. - - Sequence of network masks. - - - - Gets a matcher that allows every address. - - - - - Gets a matcher that denies every address (no masks configured). - - - - - Indicates whether this matcher has no masks configured and does not allow all. - - - - - Returns the configured masks. - - - - - Checks whether the provided address matches any of the configured masks. - - Remote address to test. - true when the address is allowed. - - - - Default authentication constants used by StellaOps resource servers and clients. - - - - - Default authentication scheme for StellaOps bearer tokens. - - - - - Logical authentication type attached to . - - - - - Policy prefix applied to named authorization policies. - - - - - Canonical claim type identifiers used across StellaOps services. - - - - - Subject identifier claim (maps to sub in JWTs). - - - - - StellaOps tenant identifier claim (multi-tenant deployments). - - - - - StellaOps project identifier claim (optional project scoping within a tenant). - - - - - OAuth2/OIDC client identifier claim (maps to client_id). - - - - - Unique token identifier claim (maps to jti). - - - - - Authentication method reference claim (amr). - - - - - Space separated scope list (scope). - - - - - Individual scope items (scp). - - - - - OAuth2 resource audiences (aud). - - - - - Identity provider hint for downstream services. - - - - - Operator reason supplied when issuing orchestrator control tokens. - - - - - Operator ticket supplied when issuing orchestrator control tokens. - - - - - Quota change reason supplied when issuing Orchestrator quota tokens. - - - - - Quota change ticket/incident reference supplied when issuing Orchestrator quota tokens. - - - - - Incident activation reason recorded when issuing observability incident tokens. - - - - - Session identifier claim (sid). - - - - - Fluent helper used to construct instances that follow StellaOps conventions. - - - - - Adds or replaces the canonical subject identifier. - - - - - Adds or replaces the canonical client identifier. - - - - - Adds or replaces the tenant identifier claim. - - - - - Adds or replaces the user display name claim. - - - - - Adds or replaces the identity provider claim. - - - - - Adds or replaces the session identifier claim. - - - - - Adds or replaces the token identifier claim. - - - - - Adds or replaces the authentication method reference claim. - - - - - Sets the name claim type appended when building the . - - - - - Sets the role claim type appended when building the . - - - - - Sets the authentication type stamped on the . - - - - - Registers the supplied scopes (normalised to lower-case, deduplicated, sorted). - - - - - Registers the supplied audiences (trimmed, deduplicated, sorted). - - - - - Adds a single audience. - - - - - Adds an arbitrary claim (no deduplication is performed). - - - - - Adds multiple claims (incoming claims are cloned to enforce value trimming). - - - - - Adds an iat (issued at) claim using Unix time seconds. - - - - - Adds an nbf (not before) claim using Unix time seconds. - - - - - Adds an exp (expires) claim using Unix time seconds. - - - - - Returns the normalised scope list (deduplicated + sorted). - - - - - Returns the normalised audience list (deduplicated + sorted). - - - - - Builds the immutable instance based on the registered data. - - - - - Factory helpers for returning RFC 7807 problem responses using StellaOps conventions. - - - - - Produces a 401 problem response indicating authentication is required. - - - - - Produces a 401 problem response for invalid, expired, or revoked tokens. - - - - - Produces a 403 problem response when access is denied. - - - - - Produces a 403 problem response for insufficient scopes. - - - - - Canonical scope names supported by StellaOps services. - - - - - Scope required to trigger Concelier jobs. - - - - - Scope required to manage Concelier merge operations. - - - - - Scope granting administrative access to Authority user management. - - - - - Scope granting administrative access to Authority client registrations. - - - - - Scope granting read-only access to Authority audit logs. - - - - - Synthetic scope representing trusted network bypass. - - - - - Scope granting read-only access to console UX features. - - - - - Scope granting permission to approve exceptions. - - - - - Scope granting read-only access to raw advisory ingestion data. - - - - - Scope granting write access for raw advisory ingestion. - - - - - Scope granting read-only access to Advisory AI artefacts (summaries, remediation exports). - - - - - Scope permitting Advisory AI inference requests and workflow execution. - - - - - Scope granting administrative control over Advisory AI configuration and profiles. - - - - - Scope granting read-only access to raw VEX ingestion data. - - - - - Scope granting write access for raw VEX ingestion. - - - - - Scope granting permission to execute aggregation-only contract verification. - - - - - Scope granting read-only access to reachability signals. - - - - - Scope granting permission to write reachability signals. - - - - - Scope granting administrative access to reachability signal ingestion. - - - - - Scope granting permission to seal or unseal an installation in air-gapped mode. - - - - - Scope granting permission to import offline bundles while in air-gapped mode. - - - - - Scope granting read-only access to air-gap status and sealing state endpoints. - - - - - Scope granting permission to create or edit policy drafts. - - - - - Scope granting permission to author Policy Studio workspaces. - - - - - Scope granting permission to edit policy configurations. - - - - - Scope granting read-only access to policy metadata. - - - - - Scope granting permission to review Policy Studio drafts. - - - - - Scope granting permission to submit drafts for review. - - - - - Scope granting permission to approve or reject policies. - - - - - Scope granting permission to operate Policy Studio promotions and runs. - - - - - Scope granting permission to audit Policy Studio activity. - - - - - Scope granting permission to trigger policy runs and activation workflows. - - - - - Scope granting permission to activate policies. - - - - - Scope granting read-only access to effective findings materialised by Policy Engine. - - - - - Scope granting permission to run Policy Studio simulations. - - - - - Scope granted to Policy Engine service identity for writing effective findings. - - - - - Scope granting read-only access to graph queries and overlays. - - - - - Scope granting read-only access to Vuln Explorer resources and permalinks. - - - - - Scope granting read-only access to observability dashboards and overlays. - - - - - Scope granting read-only access to incident timelines and chronology data. - - - - - Scope granting permission to append events to incident timelines. - - - - - Scope granting permission to create evidence packets in the evidence locker. - - - - - Scope granting read-only access to stored evidence packets. - - - - - Scope granting permission to place or release legal holds on evidence packets. - - - - - Scope granting read-only access to attestation records and observer feeds. - - - - - Scope granting permission to activate or resolve observability incident mode controls. - - - - - Scope granting read-only access to export center runs and bundles. - - - - - Scope granting permission to operate export center scheduling and run execution. - - - - - Scope granting administrative control over export center retention, encryption keys, and scheduling policies. - - - - - Scope granting read-only access to notifier channels, rules, and delivery history. - - - - - Scope permitting notifier rule management, delivery actions, and channel operations. - - - - - Scope granting administrative control over notifier secrets, escalations, and platform-wide settings. - - - - - Scope granting read-only access to issuer directory catalogues. - - - - - Scope permitting creation and modification of issuer directory entries. - - - - - Scope granting administrative control over issuer directory resources (delete, audit bypass). - - - - - Scope required to issue or honour escalation actions for notifications. - - - - - Scope granting read-only access to Task Packs catalogues and manifests. - - - - - Scope permitting publication or updates to Task Packs in the registry. - - - - - Scope granting permission to execute Task Packs via CLI or Task Runner. - - - - - Scope granting permission to fulfil Task Pack approval gates. - - - - - Scope granting permission to enqueue or mutate graph build jobs. - - - - - Scope granting permission to export graph artefacts (GraphML/JSONL/etc.). - - - - - Scope granting permission to trigger what-if simulations on graphs. - - - - - Scope granting read-only access to Orchestrator job state and telemetry. - - - - - Scope granting permission to execute Orchestrator control actions. - - - - - Scope granting permission to manage Orchestrator quotas and elevated backfill tooling. - - - - - Scope granting read-only access to Authority tenant catalog APIs. - - - - - Normalises a scope string (trim/convert to lower case). - - Scope raw value. - Normalised scope or null when the input is blank. - - - - Checks whether the provided scope is registered as a built-in StellaOps scope. - - - - - Returns the full set of built-in scopes. - - - - - Canonical identifiers for StellaOps service principals. - - - - - Service identity used by Policy Engine when materialising effective findings. - - - - - Service identity used by Cartographer when constructing and maintaining graph projections. - - - - - Service identity used by Vuln Explorer when issuing scoped permalink requests. - - - - - Service identity used by Signals components when managing reachability facts. - - - - - Shared tenancy default values used across StellaOps services. - - - - - Sentinel value indicating the token is not scoped to a specific project. - - - - + + + + StellaOps.Auth.Abstractions + + + + + Canonical telemetry metadata for the StellaOps Authority stack. + + + + + service.name resource attribute recorded by Authority components. + + + + + service.namespace resource attribute aligning Authority with other StellaOps services. + + + + + Activity source identifier used by Authority instrumentation. + + + + + Meter name used by Authority instrumentation. + + + + + Builds the default set of resource attributes (service name/namespace/version). + + Optional assembly used to resolve the service version. + + + + Resolves the service version string from the provided assembly (defaults to the Authority telemetry assembly). + + + + + Represents an IP network expressed in CIDR notation. + + + + + Initialises a new . + + Canonical network address with host bits zeroed. + Prefix length (0-32 for IPv4, 0-128 for IPv6). + + + + Canonical network address with host bits zeroed. + + + + + Prefix length. + + + + + Attempts to parse the supplied value as CIDR notation or a single IP address. + + Thrown when the input is not recognised. + + + + Attempts to parse the supplied value as CIDR notation or a single IP address. + + + + + Determines whether the provided address belongs to this network. + + + + + + + + Evaluates remote addresses against configured network masks. + + + + + Creates a matcher from raw CIDR strings. + + Sequence of CIDR entries or IP addresses. + Thrown when a value cannot be parsed. + + + + Creates a matcher from already parsed masks. + + Sequence of network masks. + + + + Gets a matcher that allows every address. + + + + + Gets a matcher that denies every address (no masks configured). + + + + + Indicates whether this matcher has no masks configured and does not allow all. + + + + + Returns the configured masks. + + + + + Checks whether the provided address matches any of the configured masks. + + Remote address to test. + true when the address is allowed. + + + + Default authentication constants used by StellaOps resource servers and clients. + + + + + Default authentication scheme for StellaOps bearer tokens. + + + + + Logical authentication type attached to . + + + + + Policy prefix applied to named authorization policies. + + + + + Canonical claim type identifiers used across StellaOps services. + + + + + Subject identifier claim (maps to sub in JWTs). + + + + + StellaOps tenant identifier claim (multi-tenant deployments). + + + + + StellaOps project identifier claim (optional project scoping within a tenant). + + + + + OAuth2/OIDC client identifier claim (maps to client_id). + + + + + Unique token identifier claim (maps to jti). + + + + + Authentication method reference claim (amr). + + + + + Space separated scope list (scope). + + + + + Individual scope items (scp). + + + + + OAuth2 resource audiences (aud). + + + + + Identity provider hint for downstream services. + + + + + Operator reason supplied when issuing orchestrator control tokens. + + + + + Operator ticket supplied when issuing orchestrator control tokens. + + + + + Quota change reason supplied when issuing Orchestrator quota tokens. + + + + + Quota change ticket/incident reference supplied when issuing Orchestrator quota tokens. + + + + + Incident activation reason recorded when issuing observability incident tokens. + + + + + Session identifier claim (sid). + + + + + Fluent helper used to construct instances that follow StellaOps conventions. + + + + + Adds or replaces the canonical subject identifier. + + + + + Adds or replaces the canonical client identifier. + + + + + Adds or replaces the tenant identifier claim. + + + + + Adds or replaces the user display name claim. + + + + + Adds or replaces the identity provider claim. + + + + + Adds or replaces the session identifier claim. + + + + + Adds or replaces the token identifier claim. + + + + + Adds or replaces the authentication method reference claim. + + + + + Sets the name claim type appended when building the . + + + + + Sets the role claim type appended when building the . + + + + + Sets the authentication type stamped on the . + + + + + Registers the supplied scopes (normalised to lower-case, deduplicated, sorted). + + + + + Registers the supplied audiences (trimmed, deduplicated, sorted). + + + + + Adds a single audience. + + + + + Adds an arbitrary claim (no deduplication is performed). + + + + + Adds multiple claims (incoming claims are cloned to enforce value trimming). + + + + + Adds an iat (issued at) claim using Unix time seconds. + + + + + Adds an nbf (not before) claim using Unix time seconds. + + + + + Adds an exp (expires) claim using Unix time seconds. + + + + + Returns the normalised scope list (deduplicated + sorted). + + + + + Returns the normalised audience list (deduplicated + sorted). + + + + + Builds the immutable instance based on the registered data. + + + + + Factory helpers for returning RFC 7807 problem responses using StellaOps conventions. + + + + + Produces a 401 problem response indicating authentication is required. + + + + + Produces a 401 problem response for invalid, expired, or revoked tokens. + + + + + Produces a 403 problem response when access is denied. + + + + + Produces a 403 problem response for insufficient scopes. + + + + + Canonical scope names supported by StellaOps services. + + + + + Scope required to trigger Concelier jobs. + + + + + Scope required to manage Concelier merge operations. + + + + + Scope granting administrative access to Authority user management. + + + + + Scope granting administrative access to Authority client registrations. + + + + + Scope granting read-only access to Authority audit logs. + + + + + Synthetic scope representing trusted network bypass. + + + + + Scope granting read-only access to console UX features. + + + + + Scope granting permission to approve exceptions. + + + + + Scope granting read-only access to raw advisory ingestion data. + + + + + Scope granting write access for raw advisory ingestion. + + + + + Scope granting read-only access to Advisory AI artefacts (summaries, remediation exports). + + + + + Scope permitting Advisory AI inference requests and workflow execution. + + + + + Scope granting administrative control over Advisory AI configuration and profiles. + + + + + Scope granting read-only access to raw VEX ingestion data. + + + + + Scope granting write access for raw VEX ingestion. + + + + + Scope granting permission to execute aggregation-only contract verification. + + + + + Scope granting read-only access to reachability signals. + + + + + Scope granting permission to write reachability signals. + + + + + Scope granting administrative access to reachability signal ingestion. + + + + + Scope granting permission to seal or unseal an installation in air-gapped mode. + + + + + Scope granting permission to import offline bundles while in air-gapped mode. + + + + + Scope granting read-only access to air-gap status and sealing state endpoints. + + + + + Scope granting permission to create or edit policy drafts. + + + + + Scope granting permission to author Policy Studio workspaces. + + + + + Scope granting permission to edit policy configurations. + + + + + Scope granting read-only access to policy metadata. + + + + + Scope granting permission to review Policy Studio drafts. + + + + + Scope granting permission to submit drafts for review. + + + + + Scope granting permission to approve or reject policies. + + + + + Scope granting permission to operate Policy Studio promotions and runs. + + + + + Scope granting permission to audit Policy Studio activity. + + + + + Scope granting permission to trigger policy runs and activation workflows. + + + + + Scope granting permission to activate policies. + + + + + Scope granting read-only access to effective findings materialised by Policy Engine. + + + + + Scope granting permission to run Policy Studio simulations. + + + + + Scope granted to Policy Engine service identity for writing effective findings. + + + + + Scope granting read-only access to graph queries and overlays. + + + + + Scope granting read-only access to Vuln Explorer resources and permalinks. + + + + + Scope granting read-only access to observability dashboards and overlays. + + + + + Scope granting read-only access to incident timelines and chronology data. + + + + + Scope granting permission to append events to incident timelines. + + + + + Scope granting permission to create evidence packets in the evidence locker. + + + + + Scope granting read-only access to stored evidence packets. + + + + + Scope granting permission to place or release legal holds on evidence packets. + + + + + Scope granting read-only access to attestation records and observer feeds. + + + + + Scope granting permission to activate or resolve observability incident mode controls. + + + + + Scope granting read-only access to export center runs and bundles. + + + + + Scope granting permission to operate export center scheduling and run execution. + + + + + Scope granting administrative control over export center retention, encryption keys, and scheduling policies. + + + + + Scope granting read-only access to notifier channels, rules, and delivery history. + + + + + Scope permitting notifier rule management, delivery actions, and channel operations. + + + + + Scope granting administrative control over notifier secrets, escalations, and platform-wide settings. + + + + + Scope granting read-only access to issuer directory catalogues. + + + + + Scope permitting creation and modification of issuer directory entries. + + + + + Scope granting administrative control over issuer directory resources (delete, audit bypass). + + + + + Scope required to issue or honour escalation actions for notifications. + + + + + Scope granting read-only access to Task Packs catalogues and manifests. + + + + + Scope permitting publication or updates to Task Packs in the registry. + + + + + Scope granting permission to execute Task Packs via CLI or Task Runner. + + + + + Scope granting permission to fulfil Task Pack approval gates. + + + + + Scope granting permission to enqueue or mutate graph build jobs. + + + + + Scope granting permission to export graph artefacts (GraphML/JSONL/etc.). + + + + + Scope granting permission to trigger what-if simulations on graphs. + + + + + Scope granting read-only access to Orchestrator job state and telemetry. + + + + + Scope granting permission to execute Orchestrator control actions. + + + + + Scope granting permission to manage Orchestrator quotas and elevated backfill tooling. + + + + + Scope granting read-only access to Authority tenant catalog APIs. + + + + + Normalises a scope string (trim/convert to lower case). + + Scope raw value. + Normalised scope or null when the input is blank. + + + + Checks whether the provided scope is registered as a built-in StellaOps scope. + + + + + Returns the full set of built-in scopes. + + + + + Canonical identifiers for StellaOps service principals. + + + + + Service identity used by Policy Engine when materialising effective findings. + + + + + Service identity used by Cartographer when constructing and maintaining graph projections. + + + + + Service identity used by Vuln Explorer when issuing scoped permalink requests. + + + + + Service identity used by Signals components when managing reachability facts. + + + + + Shared tenancy default values used across StellaOps services. + + + + + Sentinel value indicating the token is not scoped to a specific project. + + + + diff --git a/out/analyzers/python/StellaOps.Auth.Client.xml b/out/analyzers/python/StellaOps.Auth.Client.xml index cd693458..30a8915a 100644 --- a/out/analyzers/python/StellaOps.Auth.Client.xml +++ b/out/analyzers/python/StellaOps.Auth.Client.xml @@ -1,233 +1,233 @@ - - - - StellaOps.Auth.Client - - - - - File-based token cache suitable for CLI/offline usage. - - - - - In-memory token cache suitable for service scenarios. - - - - - Abstraction for caching StellaOps tokens. - - - - - Retrieves a cached token entry, if present. - - - - - Stores or updates a token entry for the specified key. - - - - - Removes the cached entry for the specified key. - - - - - Abstraction for requesting tokens from StellaOps Authority. - - - - - Requests an access token using the resource owner password credentials flow. - - - - - Requests an access token using the client credentials flow. - - - - - Retrieves the cached JWKS document. - - - - - Retrieves a cached token entry. - - - - - Persists a token entry in the cache. - - - - - Removes a cached entry. - - - - - DI helpers for the StellaOps auth client. - - - - - Registers the StellaOps auth client with the provided configuration. - - - - - Registers a file-backed token cache implementation. - - - - - Options controlling the StellaOps authentication client. - - - - - Authority (issuer) base URL. - - - - - OAuth client identifier (optional for password flow). - - - - - OAuth client secret (optional for public clients). - - - - - Default scopes requested for flows that do not explicitly override them. - - - - - Retry delays applied by HTTP retry policy (empty uses defaults). - - - - - Gets or sets a value indicating whether HTTP retry policies are enabled. - - - - - Timeout applied to discovery and token HTTP requests. - - - - - Lifetime of cached discovery metadata. - - - - - Lifetime of cached JWKS metadata. - - - - - Buffer applied when determining cache expiration (default: 30 seconds). - - - - - Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. - - - - - Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. - - - - - Parsed Authority URI (populated after validation). - - - - - Normalised scope list (populated after validation). - - - - - Normalised retry delays (populated after validation). - - - - - Validates required values and normalises scope entries. - - - - - Caches Authority discovery metadata. - - - - - Minimal OpenID Connect configuration representation. - - - - - Minimal OpenID Connect configuration representation. - - - - - Caches JWKS documents for Authority. - - - - - Represents a cached token entry. - - - - - Represents a cached token entry. - - - - - Determines whether the token is expired given the provided . - - - - - Creates a copy with scopes normalised. - - - - - Default implementation of . - - - - - Represents an issued token with metadata. - - - - - Represents an issued token with metadata. - - - - - Converts the result to a cache entry. - - - - + + + + StellaOps.Auth.Client + + + + + File-based token cache suitable for CLI/offline usage. + + + + + In-memory token cache suitable for service scenarios. + + + + + Abstraction for caching StellaOps tokens. + + + + + Retrieves a cached token entry, if present. + + + + + Stores or updates a token entry for the specified key. + + + + + Removes the cached entry for the specified key. + + + + + Abstraction for requesting tokens from StellaOps Authority. + + + + + Requests an access token using the resource owner password credentials flow. + + + + + Requests an access token using the client credentials flow. + + + + + Retrieves the cached JWKS document. + + + + + Retrieves a cached token entry. + + + + + Persists a token entry in the cache. + + + + + Removes a cached entry. + + + + + DI helpers for the StellaOps auth client. + + + + + Registers the StellaOps auth client with the provided configuration. + + + + + Registers a file-backed token cache implementation. + + + + + Options controlling the StellaOps authentication client. + + + + + Authority (issuer) base URL. + + + + + OAuth client identifier (optional for password flow). + + + + + OAuth client secret (optional for public clients). + + + + + Default scopes requested for flows that do not explicitly override them. + + + + + Retry delays applied by HTTP retry policy (empty uses defaults). + + + + + Gets or sets a value indicating whether HTTP retry policies are enabled. + + + + + Timeout applied to discovery and token HTTP requests. + + + + + Lifetime of cached discovery metadata. + + + + + Lifetime of cached JWKS metadata. + + + + + Buffer applied when determining cache expiration (default: 30 seconds). + + + + + Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. + + + + + Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. + + + + + Parsed Authority URI (populated after validation). + + + + + Normalised scope list (populated after validation). + + + + + Normalised retry delays (populated after validation). + + + + + Validates required values and normalises scope entries. + + + + + Caches Authority discovery metadata. + + + + + Minimal OpenID Connect configuration representation. + + + + + Minimal OpenID Connect configuration representation. + + + + + Caches JWKS documents for Authority. + + + + + Represents a cached token entry. + + + + + Represents a cached token entry. + + + + + Determines whether the token is expired given the provided . + + + + + Creates a copy with scopes normalised. + + + + + Default implementation of . + + + + + Represents an issued token with metadata. + + + + + Represents an issued token with metadata. + + + + + Converts the result to a cache entry. + + + + diff --git a/out/analyzers/python/StellaOps.Scanner.Analyzers.Lang.Python.deps.json b/out/analyzers/python/StellaOps.Scanner.Analyzers.Lang.Python.deps.json index 529d77d8..a7dc0f10 100644 --- a/out/analyzers/python/StellaOps.Scanner.Analyzers.Lang.Python.deps.json +++ b/out/analyzers/python/StellaOps.Scanner.Analyzers.Lang.Python.deps.json @@ -1,858 +1,858 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v10.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v10.0": { - "StellaOps.Scanner.Analyzers.Lang.Python/1.0.0": { - "dependencies": { - "SharpCompress": "0.41.0", - "StellaOps.Scanner.Analyzers.Lang": "1.0.0" - }, - "runtime": { - "StellaOps.Scanner.Analyzers.Lang.Python.dll": {} - } - }, - "Konscious.Security.Cryptography.Argon2/1.3.1": { - "dependencies": { - "Konscious.Security.Cryptography.Blake2": "1.1.1" - }, - "runtime": { - "lib/net8.0/Konscious.Security.Cryptography.Argon2.dll": { - "assemblyVersion": "1.3.1.0", - "fileVersion": "1.3.1.0" - } - } - }, - "Konscious.Security.Cryptography.Blake2/1.1.1": { - "runtime": { - "lib/net8.0/Konscious.Security.Cryptography.Blake2.dll": { - "assemblyVersion": "1.1.1.0", - "fileVersion": "1.1.1.0" - } - } - }, - "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Json.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.DependencyInjection.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Diagnostics.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileProviders.Physical.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Diagnostics": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Http.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Http": "10.0.0-rc.2.25502.107", - "Polly": "7.2.4", - "Polly.Extensions.Http": "3.0.0" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.Extensions.Http.Polly.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Logging.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Options.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.Primitives.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.IdentityModel.Abstractions/8.14.0": { - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - }, - "runtime": { - "lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": { - "assemblyVersion": "7.2.0.0", - "fileVersion": "7.2.0.50110" - } - } - }, - "Microsoft.IdentityModel.Logging/8.14.0": { - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - }, - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Logging.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "Microsoft.IdentityModel.Tokens/8.14.0": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.IdentityModel.Logging": "8.14.0" - }, - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Tokens.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "NetEscapades.Configuration.Yaml/2.1.0": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "YamlDotNet": "9.1.0" - }, - "runtime": { - "lib/netstandard2.0/NetEscapades.Configuration.Yaml.dll": { - "assemblyVersion": "2.1.0.0", - "fileVersion": "2.1.0.0" - } - } - }, - "Pipelines.Sockets.Unofficial/2.2.8": { - "runtime": { - "lib/net5.0/Pipelines.Sockets.Unofficial.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "2.2.8.1080" - } - } - }, - "Polly/7.2.4": { - "runtime": { - "lib/netstandard2.0/Polly.dll": { - "assemblyVersion": "7.0.0.0", - "fileVersion": "7.2.4.982" - } - } - }, - "Polly.Extensions.Http/3.0.0": { - "dependencies": { - "Polly": "7.2.4" - }, - "runtime": { - "lib/netstandard2.0/Polly.Extensions.Http.dll": { - "assemblyVersion": "3.0.0.0", - "fileVersion": "3.0.0.0" - } - } - }, - "SharpCompress/0.41.0": { - "dependencies": { - "ZstdSharp.Port": "0.8.6" - }, - "runtime": { - "lib/net8.0/SharpCompress.dll": { - "assemblyVersion": "0.41.0.0", - "fileVersion": "0.41.0.0" - } - } - }, - "StackExchange.Redis/2.8.24": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Pipelines.Sockets.Unofficial": "2.2.8" - }, - "runtime": { - "lib/net8.0/StackExchange.Redis.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.8.24.3255" - } - } - }, - "System.IdentityModel.Tokens.Jwt/7.2.0": { - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "7.2.0", - "Microsoft.IdentityModel.Tokens": "8.14.0" - }, - "runtime": { - "lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": { - "assemblyVersion": "7.2.0.0", - "fileVersion": "7.2.0.50110" - } - } - }, - "YamlDotNet/9.1.0": { - "runtime": { - "lib/netstandard2.1/YamlDotNet.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.1.0.0" - } - } - }, - "ZstdSharp.Port/0.8.6": { - "runtime": { - "lib/net9.0/ZstdSharp.dll": { - "assemblyVersion": "0.8.6.0", - "fileVersion": "0.8.6.0" - } - } - }, - "StellaOps.Auth.Abstractions/1.0.0-preview.1": { - "dependencies": { - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.Auth.Abstractions.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Auth.Client/1.0.0-preview.1": { - "dependencies": { - "Microsoft.Extensions.Http.Polly": "10.0.0-rc.2.25502.107", - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Abstractions": "1.0.0-preview.1", - "StellaOps.Configuration": "1.0.0" - }, - "runtime": { - "StellaOps.Auth.Client.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Auth.Security/1.0.0-preview.1": { - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0", - "StackExchange.Redis": "2.8.24", - "System.IdentityModel.Tokens.Jwt": "7.2.0" - }, - "runtime": { - "StellaOps.Auth.Security.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Authority.Plugins.Abstractions/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Abstractions": "1.0.0-preview.1", - "StellaOps.Cryptography": "1.0.0" - }, - "runtime": { - "StellaOps.Authority.Plugins.Abstractions.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Configuration/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Json": "10.0.0-rc.2.25502.107", - "NetEscapades.Configuration.Yaml": "2.1.0", - "SharpCompress": "0.41.0", - "StellaOps.Authority.Plugins.Abstractions": "1.0.0", - "StellaOps.Cryptography": "1.0.0" - }, - "runtime": { - "StellaOps.Configuration.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Cryptography/1.0.0": { - "dependencies": { - "Konscious.Security.Cryptography.Argon2": "1.3.1", - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.Cryptography.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.DependencyInjection/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.DependencyInjection.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Plugin/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.DependencyInjection": "1.0.0" - }, - "runtime": { - "StellaOps.Plugin.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Scanner.Analyzers.Lang/1.0.0": { - "dependencies": { - "SharpCompress": "0.41.0", - "StellaOps.Plugin": "1.0.0", - "StellaOps.Scanner.Core": "1.0.0" - }, - "runtime": { - "StellaOps.Scanner.Analyzers.Lang.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Scanner.Core/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Client": "1.0.0-preview.1", - "StellaOps.Auth.Security": "1.0.0-preview.1" - }, - "runtime": { - "StellaOps.Scanner.Core.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - } - } - }, - "libraries": { - "StellaOps.Scanner.Analyzers.Lang.Python/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Konscious.Security.Cryptography.Argon2/1.3.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-T+OAGwzYYXftahpOxO7J4xA5K6urxwGnWQf3M+Jpi+76Azv/0T3M5SuN+h7/QvXuiqNw3ZEZ5QqVLI5ygDAylw==", - "path": "konscious.security.cryptography.argon2/1.3.1", - "hashPath": "konscious.security.cryptography.argon2.1.3.1.nupkg.sha512" - }, - "Konscious.Security.Cryptography.Blake2/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-odwOyzj/J/lHJZNwFWJGU/LRecBShupAJ2S8TQqZfhUe9niHzu/voBYK5wuVKsvSpzbfupKQYZguVyIk1sgOkQ==", - "path": "konscious.security.cryptography.blake2/1.1.1", - "hashPath": "konscious.security.cryptography.blake2.1.1.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-273Ggibh3DdVrj47ENbUGIirOiqmLTAizpkvOD584Ps6NL/CMXPzesijnJgsjp7Fv/UCp69FKYBaSxZZ3q5R9g==", - "path": "microsoft.extensions.configuration/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-H+i/Qy30Rg/K9BcW2Z6DCHPCzwMH3bCwNOjEz31shWTUDK8GeeeMnrKVusprTcRA2Y6yPST+hg2zc3whPEs14Q==", - "path": "microsoft.extensions.configuration.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-aA6/V6lw1Gueyb1PqhHAl/i/qUUuv+Fusfk4oaMOzzOjspBkYtPpNHCmml/0t1x0/DnZoed+u2WwpP+mSwd8Dg==", - "path": "microsoft.extensions.configuration.binder/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.binder.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-2SV60IUAWfluZv2YHNZ+nUOljYHGIsy96FpJs+N9/bgKDYs9qr6DdzPeIhiHrz+XvRzbybvcwtTBf5dKrYN4oA==", - "path": "microsoft.extensions.configuration.environmentvariables/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.environmentvariables.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5KrgXSTFR8cFLmDXXoT7GLVvDyHNw0Z9xG4doD78Q/HdlAR4jiMzmLLS9GFXrPGopmC6qqEZr2VBJHEu16INcA==", - "path": "microsoft.extensions.configuration.fileextensions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.fileextensions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-USwHuFz4BFKoaqSydHWH/d7Mr+fVsAh9S0S9pdsdHro1IixMbqQ9Gpo2sEZf25e3tZSq/ts6XsVmrQWmxmDhYA==", - "path": "microsoft.extensions.configuration.json/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.json.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-mDw80K98jBWCyLFCra51PRv+Ttnjse1lZIzXEFybKby0/ajBFTEeHj/4r/QJexmb8Uun0yaFH1HlFtmHP1YEVA==", - "path": "microsoft.extensions.dependencyinjection/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.dependencyinjection.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-8jujunpkNNfTkE9PFHp9/aD6GPKVfNCuz8tUbzOcyU5tQOCoIZId4hwQNVx3Tb8XEWw9BYdh0k5vPpqdCM+UtA==", - "path": "microsoft.extensions.dependencyinjection.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-tQfQFXI+ZQcL2RzDarDLx3Amh0WCp1KPGp1ie3y/CMV5hDhEq98WTmcMoXrFY0GkYLEaCQlVi2A6qVLcooG2Ow==", - "path": "microsoft.extensions.diagnostics/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.diagnostics.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-x6XVv3RiwOlN2unjyX/Zat0gI0HiRoDDdjkwBCwsMftYWpbJu4SiyRwDbrv2zAF8v8nbEEvcWi3/pUxZfaqLQw==", - "path": "microsoft.extensions.diagnostics.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.diagnostics.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dOpmW14MkOZIwV6269iXhoMp6alCHBoxqCR4pJ37GLjFaBIyzsIy+Ra8tsGmjHtFvEHKq0JRDIsb1PUkrK+yxw==", - "path": "microsoft.extensions.fileproviders.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.fileproviders.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-3+RiR6TEakDL0dCUqR7PjFffyrVMLdx/vAVBiN1mGmwScKYCTePIkYVkWsX85CTKh7R9J4M9C1MHzVdjbKcg3g==", - "path": "microsoft.extensions.fileproviders.physical/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.fileproviders.physical.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XtcPOKB7sMFzj8SxaOglZV3eaqZ1GxUMVZTwaz4pRpBt0S45ghb836uUej4YaI8EzsnUJoqzOIKrTW4CDJMfVw==", - "path": "microsoft.extensions.filesystemglobbing/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.filesystemglobbing.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-d60bvi/NpzkpVlSpxZqOfdjX1hrQgL/byWVc3PryjbmB7zvfLtqQbYifjEWToqtS0Fb1rGnkuVI5JEdOnK1tNQ==", - "path": "microsoft.extensions.http/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.http.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-aY5vLcrhdXCHsCjYI2lNwfat2vdSuiPs0FFZiy7IM6zcyqdxaefG8J8ezTKkZyiuAtznjVJJT70B660l/WlsxA==", - "path": "microsoft.extensions.http.polly/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.http.polly.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-q2C5gq86qkTmcYSJJSnw8sgTUyuqENYSOjk/NOYjHnYlKSrK3oI9Rjv1bWFpx2I3Btq9ZBEJb9aMM+IUQ0PvZA==", - "path": "microsoft.extensions.logging/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.logging.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-SKKKZjyCpBaDQ7yuFjdk6ELnRBRWeZsbnzUfo59Wc4PGhgf92chE3we/QlT6nk6NqlWcUgH/jogM+B/uq/Qdnw==", - "path": "microsoft.extensions.logging.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.logging.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Ib6BCCjisp7ZUdhtNpSulFO0ODhz/IE4ZZd8OCqQWoRs363BQ0QOZi9KwpqpiEWo51S0kIXWqNicDPGXwpt9pQ==", - "path": "microsoft.extensions.options/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.options.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-MFbT8+JKX49YCXEFvlZDzQzI/R3QKzRZlb4dSud+569cMgA9hWbndjWWvOgGASoRcXynGRrBSq1Bw3PeCsB5/Q==", - "path": "microsoft.extensions.options.configurationextensions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.options.configurationextensions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-9pm2zqqn5u/OsKs2zgkhJEQQeMx9KkVOWPdHrs7Kt5sfpk+eIh/gmpi/mMH/ljS2T/PFsFdCEtm+GS/6l7zoZA==", - "path": "microsoft.extensions.primitives/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.primitives.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.IdentityModel.Abstractions/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==", - "path": "microsoft.identitymodel.abstractions/8.14.0", - "hashPath": "microsoft.identitymodel.abstractions.8.14.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-zLFA9IBxDWw6Y1nz2PPZyQvF+ZZ4aW1pwgtwusQB39lgxOc2xVqZ8gitsuT1rwyuIbchGOWbax4fsJ8OgGRxSQ==", - "path": "microsoft.identitymodel.jsonwebtokens/7.2.0", - "hashPath": "microsoft.identitymodel.jsonwebtokens.7.2.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.Logging/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "path": "microsoft.identitymodel.logging/8.14.0", - "hashPath": "microsoft.identitymodel.logging.8.14.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.Tokens/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ySPkj429HrYHvwLVNoPZdQ/bKZZKSkuWKod68qxo+5/pLdXFimgflckKgAZclX9tuO9qWk/KFiIN65diMWgh+g==", - "path": "microsoft.identitymodel.tokens/8.14.0", - "hashPath": "microsoft.identitymodel.tokens.8.14.0.nupkg.sha512" - }, - "NetEscapades.Configuration.Yaml/2.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-kNTX7kvRvbzBpLd3Vg9iu6t60tTyhVxsruAPgH6kl1GkAZIHLZw9cQysvjUenDU7JEnUgyxQnzfL8627ARDn+g==", - "path": "netescapades.configuration.yaml/2.1.0", - "hashPath": "netescapades.configuration.yaml.2.1.0.nupkg.sha512" - }, - "Pipelines.Sockets.Unofficial/2.2.8": { - "type": "package", - "serviceable": true, - "sha512": "sha512-zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", - "path": "pipelines.sockets.unofficial/2.2.8", - "hashPath": "pipelines.sockets.unofficial.2.2.8.nupkg.sha512" - }, - "Polly/7.2.4": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bw00Ck5sh6ekduDE3mnCo1ohzuad946uslCDEENu3091+6UKnBuKLo4e+yaNcCzXxOZCXWY2gV4a35+K1d4LDA==", - "path": "polly/7.2.4", - "hashPath": "polly.7.2.4.nupkg.sha512" - }, - "Polly.Extensions.Http/3.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", - "path": "polly.extensions.http/3.0.0", - "hashPath": "polly.extensions.http.3.0.0.nupkg.sha512" - }, - "SharpCompress/0.41.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-z04dBVdTIAFTRKi38f0LkajaKA++bR+M8kYCbasXePILD2H+qs7CkLpyiippB24CSbTrWIgpBKm6BenZqkUwvw==", - "path": "sharpcompress/0.41.0", - "hashPath": "sharpcompress.0.41.0.nupkg.sha512" - }, - "StackExchange.Redis/2.8.24": { - "type": "package", - "serviceable": true, - "sha512": "sha512-GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", - "path": "stackexchange.redis/2.8.24", - "hashPath": "stackexchange.redis.2.8.24.nupkg.sha512" - }, - "System.IdentityModel.Tokens.Jwt/7.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Z3Fmkrxkp+o51ANMO/PqASRRlEz8dH4mTWwZXMFMXZt2bUGztBiNcIDnwBCElYLYpzpmz4sIqHb6aW8QVLe6YQ==", - "path": "system.identitymodel.tokens.jwt/7.2.0", - "hashPath": "system.identitymodel.tokens.jwt.7.2.0.nupkg.sha512" - }, - "YamlDotNet/9.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-fuvGXU4Ec5HrsmEc+BiFTNPCRf1cGBI2kh/3RzMWgddM2M4ALhbSPoI3X3mhXZUD1qqQd9oSkFAtWjpz8z9eRg==", - "path": "yamldotnet/9.1.0", - "hashPath": "yamldotnet.9.1.0.nupkg.sha512" - }, - "ZstdSharp.Port/0.8.6": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iP4jVLQoQmUjMU88g1WObiNr6YKZGvh4aOXn3yOJsHqZsflwRsxZPcIBvNXgjXO3vQKSLctXGLTpcBPLnWPS8A==", - "path": "zstdsharp.port/0.8.6", - "hashPath": "zstdsharp.port.0.8.6.nupkg.sha512" - }, - "StellaOps.Auth.Abstractions/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Auth.Client/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Auth.Security/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Authority.Plugins.Abstractions/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Configuration/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Cryptography/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.DependencyInjection/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Plugin/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Scanner.Analyzers.Lang/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Scanner.Core/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - } - } +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "StellaOps.Scanner.Analyzers.Lang.Python/1.0.0": { + "dependencies": { + "SharpCompress": "0.41.0", + "StellaOps.Scanner.Analyzers.Lang": "1.0.0" + }, + "runtime": { + "StellaOps.Scanner.Analyzers.Lang.Python.dll": {} + } + }, + "Konscious.Security.Cryptography.Argon2/1.3.1": { + "dependencies": { + "Konscious.Security.Cryptography.Blake2": "1.1.1" + }, + "runtime": { + "lib/net8.0/Konscious.Security.Cryptography.Argon2.dll": { + "assemblyVersion": "1.3.1.0", + "fileVersion": "1.3.1.0" + } + } + }, + "Konscious.Security.Cryptography.Blake2/1.1.1": { + "runtime": { + "lib/net8.0/Konscious.Security.Cryptography.Blake2.dll": { + "assemblyVersion": "1.1.1.0", + "fileVersion": "1.1.1.0" + } + } + }, + "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Json.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.DependencyInjection.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Diagnostics.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileProviders.Physical.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Diagnostics": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Http.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Http": "10.0.0-rc.2.25502.107", + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.Extensions.Http.Polly.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Logging.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Options.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.Primitives.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.IdentityModel.Abstractions/8.14.0": { + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.14.0" + }, + "runtime": { + "lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": { + "assemblyVersion": "7.2.0.0", + "fileVersion": "7.2.0.50110" + } + } + }, + "Microsoft.IdentityModel.Logging/8.14.0": { + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0" + }, + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Logging.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "Microsoft.IdentityModel.Tokens/8.14.0": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.IdentityModel.Logging": "8.14.0" + }, + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Tokens.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "NetEscapades.Configuration.Yaml/2.1.0": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "YamlDotNet": "9.1.0" + }, + "runtime": { + "lib/netstandard2.0/NetEscapades.Configuration.Yaml.dll": { + "assemblyVersion": "2.1.0.0", + "fileVersion": "2.1.0.0" + } + } + }, + "Pipelines.Sockets.Unofficial/2.2.8": { + "runtime": { + "lib/net5.0/Pipelines.Sockets.Unofficial.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "2.2.8.1080" + } + } + }, + "Polly/7.2.4": { + "runtime": { + "lib/netstandard2.0/Polly.dll": { + "assemblyVersion": "7.0.0.0", + "fileVersion": "7.2.4.982" + } + } + }, + "Polly.Extensions.Http/3.0.0": { + "dependencies": { + "Polly": "7.2.4" + }, + "runtime": { + "lib/netstandard2.0/Polly.Extensions.Http.dll": { + "assemblyVersion": "3.0.0.0", + "fileVersion": "3.0.0.0" + } + } + }, + "SharpCompress/0.41.0": { + "dependencies": { + "ZstdSharp.Port": "0.8.6" + }, + "runtime": { + "lib/net8.0/SharpCompress.dll": { + "assemblyVersion": "0.41.0.0", + "fileVersion": "0.41.0.0" + } + } + }, + "StackExchange.Redis/2.8.24": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Pipelines.Sockets.Unofficial": "2.2.8" + }, + "runtime": { + "lib/net8.0/StackExchange.Redis.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.8.24.3255" + } + } + }, + "System.IdentityModel.Tokens.Jwt/7.2.0": { + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.2.0", + "Microsoft.IdentityModel.Tokens": "8.14.0" + }, + "runtime": { + "lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": { + "assemblyVersion": "7.2.0.0", + "fileVersion": "7.2.0.50110" + } + } + }, + "YamlDotNet/9.1.0": { + "runtime": { + "lib/netstandard2.1/YamlDotNet.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.1.0.0" + } + } + }, + "ZstdSharp.Port/0.8.6": { + "runtime": { + "lib/net9.0/ZstdSharp.dll": { + "assemblyVersion": "0.8.6.0", + "fileVersion": "0.8.6.0" + } + } + }, + "StellaOps.Auth.Abstractions/1.0.0-preview.1": { + "dependencies": { + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.Auth.Abstractions.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Auth.Client/1.0.0-preview.1": { + "dependencies": { + "Microsoft.Extensions.Http.Polly": "10.0.0-rc.2.25502.107", + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Abstractions": "1.0.0-preview.1", + "StellaOps.Configuration": "1.0.0" + }, + "runtime": { + "StellaOps.Auth.Client.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Auth.Security/1.0.0-preview.1": { + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0", + "StackExchange.Redis": "2.8.24", + "System.IdentityModel.Tokens.Jwt": "7.2.0" + }, + "runtime": { + "StellaOps.Auth.Security.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Authority.Plugins.Abstractions/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Abstractions": "1.0.0-preview.1", + "StellaOps.Cryptography": "1.0.0" + }, + "runtime": { + "StellaOps.Authority.Plugins.Abstractions.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Configuration/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Json": "10.0.0-rc.2.25502.107", + "NetEscapades.Configuration.Yaml": "2.1.0", + "SharpCompress": "0.41.0", + "StellaOps.Authority.Plugins.Abstractions": "1.0.0", + "StellaOps.Cryptography": "1.0.0" + }, + "runtime": { + "StellaOps.Configuration.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Cryptography/1.0.0": { + "dependencies": { + "Konscious.Security.Cryptography.Argon2": "1.3.1", + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.Cryptography.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.DependencyInjection/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.DependencyInjection.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Plugin/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.DependencyInjection": "1.0.0" + }, + "runtime": { + "StellaOps.Plugin.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Scanner.Analyzers.Lang/1.0.0": { + "dependencies": { + "SharpCompress": "0.41.0", + "StellaOps.Plugin": "1.0.0", + "StellaOps.Scanner.Core": "1.0.0" + }, + "runtime": { + "StellaOps.Scanner.Analyzers.Lang.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Scanner.Core/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Client": "1.0.0-preview.1", + "StellaOps.Auth.Security": "1.0.0-preview.1" + }, + "runtime": { + "StellaOps.Scanner.Core.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "StellaOps.Scanner.Analyzers.Lang.Python/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Konscious.Security.Cryptography.Argon2/1.3.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-T+OAGwzYYXftahpOxO7J4xA5K6urxwGnWQf3M+Jpi+76Azv/0T3M5SuN+h7/QvXuiqNw3ZEZ5QqVLI5ygDAylw==", + "path": "konscious.security.cryptography.argon2/1.3.1", + "hashPath": "konscious.security.cryptography.argon2.1.3.1.nupkg.sha512" + }, + "Konscious.Security.Cryptography.Blake2/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-odwOyzj/J/lHJZNwFWJGU/LRecBShupAJ2S8TQqZfhUe9niHzu/voBYK5wuVKsvSpzbfupKQYZguVyIk1sgOkQ==", + "path": "konscious.security.cryptography.blake2/1.1.1", + "hashPath": "konscious.security.cryptography.blake2.1.1.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-273Ggibh3DdVrj47ENbUGIirOiqmLTAizpkvOD584Ps6NL/CMXPzesijnJgsjp7Fv/UCp69FKYBaSxZZ3q5R9g==", + "path": "microsoft.extensions.configuration/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-H+i/Qy30Rg/K9BcW2Z6DCHPCzwMH3bCwNOjEz31shWTUDK8GeeeMnrKVusprTcRA2Y6yPST+hg2zc3whPEs14Q==", + "path": "microsoft.extensions.configuration.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-aA6/V6lw1Gueyb1PqhHAl/i/qUUuv+Fusfk4oaMOzzOjspBkYtPpNHCmml/0t1x0/DnZoed+u2WwpP+mSwd8Dg==", + "path": "microsoft.extensions.configuration.binder/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.binder.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-2SV60IUAWfluZv2YHNZ+nUOljYHGIsy96FpJs+N9/bgKDYs9qr6DdzPeIhiHrz+XvRzbybvcwtTBf5dKrYN4oA==", + "path": "microsoft.extensions.configuration.environmentvariables/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.environmentvariables.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5KrgXSTFR8cFLmDXXoT7GLVvDyHNw0Z9xG4doD78Q/HdlAR4jiMzmLLS9GFXrPGopmC6qqEZr2VBJHEu16INcA==", + "path": "microsoft.extensions.configuration.fileextensions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.fileextensions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-USwHuFz4BFKoaqSydHWH/d7Mr+fVsAh9S0S9pdsdHro1IixMbqQ9Gpo2sEZf25e3tZSq/ts6XsVmrQWmxmDhYA==", + "path": "microsoft.extensions.configuration.json/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.json.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-mDw80K98jBWCyLFCra51PRv+Ttnjse1lZIzXEFybKby0/ajBFTEeHj/4r/QJexmb8Uun0yaFH1HlFtmHP1YEVA==", + "path": "microsoft.extensions.dependencyinjection/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.dependencyinjection.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-8jujunpkNNfTkE9PFHp9/aD6GPKVfNCuz8tUbzOcyU5tQOCoIZId4hwQNVx3Tb8XEWw9BYdh0k5vPpqdCM+UtA==", + "path": "microsoft.extensions.dependencyinjection.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-tQfQFXI+ZQcL2RzDarDLx3Amh0WCp1KPGp1ie3y/CMV5hDhEq98WTmcMoXrFY0GkYLEaCQlVi2A6qVLcooG2Ow==", + "path": "microsoft.extensions.diagnostics/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.diagnostics.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-x6XVv3RiwOlN2unjyX/Zat0gI0HiRoDDdjkwBCwsMftYWpbJu4SiyRwDbrv2zAF8v8nbEEvcWi3/pUxZfaqLQw==", + "path": "microsoft.extensions.diagnostics.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.diagnostics.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dOpmW14MkOZIwV6269iXhoMp6alCHBoxqCR4pJ37GLjFaBIyzsIy+Ra8tsGmjHtFvEHKq0JRDIsb1PUkrK+yxw==", + "path": "microsoft.extensions.fileproviders.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.fileproviders.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3+RiR6TEakDL0dCUqR7PjFffyrVMLdx/vAVBiN1mGmwScKYCTePIkYVkWsX85CTKh7R9J4M9C1MHzVdjbKcg3g==", + "path": "microsoft.extensions.fileproviders.physical/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.fileproviders.physical.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XtcPOKB7sMFzj8SxaOglZV3eaqZ1GxUMVZTwaz4pRpBt0S45ghb836uUej4YaI8EzsnUJoqzOIKrTW4CDJMfVw==", + "path": "microsoft.extensions.filesystemglobbing/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.filesystemglobbing.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-d60bvi/NpzkpVlSpxZqOfdjX1hrQgL/byWVc3PryjbmB7zvfLtqQbYifjEWToqtS0Fb1rGnkuVI5JEdOnK1tNQ==", + "path": "microsoft.extensions.http/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.http.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-aY5vLcrhdXCHsCjYI2lNwfat2vdSuiPs0FFZiy7IM6zcyqdxaefG8J8ezTKkZyiuAtznjVJJT70B660l/WlsxA==", + "path": "microsoft.extensions.http.polly/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.http.polly.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-q2C5gq86qkTmcYSJJSnw8sgTUyuqENYSOjk/NOYjHnYlKSrK3oI9Rjv1bWFpx2I3Btq9ZBEJb9aMM+IUQ0PvZA==", + "path": "microsoft.extensions.logging/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.logging.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SKKKZjyCpBaDQ7yuFjdk6ELnRBRWeZsbnzUfo59Wc4PGhgf92chE3we/QlT6nk6NqlWcUgH/jogM+B/uq/Qdnw==", + "path": "microsoft.extensions.logging.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.logging.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Ib6BCCjisp7ZUdhtNpSulFO0ODhz/IE4ZZd8OCqQWoRs363BQ0QOZi9KwpqpiEWo51S0kIXWqNicDPGXwpt9pQ==", + "path": "microsoft.extensions.options/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.options.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-MFbT8+JKX49YCXEFvlZDzQzI/R3QKzRZlb4dSud+569cMgA9hWbndjWWvOgGASoRcXynGRrBSq1Bw3PeCsB5/Q==", + "path": "microsoft.extensions.options.configurationextensions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.options.configurationextensions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-9pm2zqqn5u/OsKs2zgkhJEQQeMx9KkVOWPdHrs7Kt5sfpk+eIh/gmpi/mMH/ljS2T/PFsFdCEtm+GS/6l7zoZA==", + "path": "microsoft.extensions.primitives/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.primitives.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.IdentityModel.Abstractions/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==", + "path": "microsoft.identitymodel.abstractions/8.14.0", + "hashPath": "microsoft.identitymodel.abstractions.8.14.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zLFA9IBxDWw6Y1nz2PPZyQvF+ZZ4aW1pwgtwusQB39lgxOc2xVqZ8gitsuT1rwyuIbchGOWbax4fsJ8OgGRxSQ==", + "path": "microsoft.identitymodel.jsonwebtokens/7.2.0", + "hashPath": "microsoft.identitymodel.jsonwebtokens.7.2.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.Logging/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "path": "microsoft.identitymodel.logging/8.14.0", + "hashPath": "microsoft.identitymodel.logging.8.14.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.Tokens/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ySPkj429HrYHvwLVNoPZdQ/bKZZKSkuWKod68qxo+5/pLdXFimgflckKgAZclX9tuO9qWk/KFiIN65diMWgh+g==", + "path": "microsoft.identitymodel.tokens/8.14.0", + "hashPath": "microsoft.identitymodel.tokens.8.14.0.nupkg.sha512" + }, + "NetEscapades.Configuration.Yaml/2.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kNTX7kvRvbzBpLd3Vg9iu6t60tTyhVxsruAPgH6kl1GkAZIHLZw9cQysvjUenDU7JEnUgyxQnzfL8627ARDn+g==", + "path": "netescapades.configuration.yaml/2.1.0", + "hashPath": "netescapades.configuration.yaml.2.1.0.nupkg.sha512" + }, + "Pipelines.Sockets.Unofficial/2.2.8": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "path": "pipelines.sockets.unofficial/2.2.8", + "hashPath": "pipelines.sockets.unofficial.2.2.8.nupkg.sha512" + }, + "Polly/7.2.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bw00Ck5sh6ekduDE3mnCo1ohzuad946uslCDEENu3091+6UKnBuKLo4e+yaNcCzXxOZCXWY2gV4a35+K1d4LDA==", + "path": "polly/7.2.4", + "hashPath": "polly.7.2.4.nupkg.sha512" + }, + "Polly.Extensions.Http/3.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "path": "polly.extensions.http/3.0.0", + "hashPath": "polly.extensions.http.3.0.0.nupkg.sha512" + }, + "SharpCompress/0.41.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-z04dBVdTIAFTRKi38f0LkajaKA++bR+M8kYCbasXePILD2H+qs7CkLpyiippB24CSbTrWIgpBKm6BenZqkUwvw==", + "path": "sharpcompress/0.41.0", + "hashPath": "sharpcompress.0.41.0.nupkg.sha512" + }, + "StackExchange.Redis/2.8.24": { + "type": "package", + "serviceable": true, + "sha512": "sha512-GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", + "path": "stackexchange.redis/2.8.24", + "hashPath": "stackexchange.redis.2.8.24.nupkg.sha512" + }, + "System.IdentityModel.Tokens.Jwt/7.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Z3Fmkrxkp+o51ANMO/PqASRRlEz8dH4mTWwZXMFMXZt2bUGztBiNcIDnwBCElYLYpzpmz4sIqHb6aW8QVLe6YQ==", + "path": "system.identitymodel.tokens.jwt/7.2.0", + "hashPath": "system.identitymodel.tokens.jwt.7.2.0.nupkg.sha512" + }, + "YamlDotNet/9.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-fuvGXU4Ec5HrsmEc+BiFTNPCRf1cGBI2kh/3RzMWgddM2M4ALhbSPoI3X3mhXZUD1qqQd9oSkFAtWjpz8z9eRg==", + "path": "yamldotnet/9.1.0", + "hashPath": "yamldotnet.9.1.0.nupkg.sha512" + }, + "ZstdSharp.Port/0.8.6": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iP4jVLQoQmUjMU88g1WObiNr6YKZGvh4aOXn3yOJsHqZsflwRsxZPcIBvNXgjXO3vQKSLctXGLTpcBPLnWPS8A==", + "path": "zstdsharp.port/0.8.6", + "hashPath": "zstdsharp.port.0.8.6.nupkg.sha512" + }, + "StellaOps.Auth.Abstractions/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Auth.Client/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Auth.Security/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Authority.Plugins.Abstractions/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Configuration/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Cryptography/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.DependencyInjection/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Plugin/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Scanner.Analyzers.Lang/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Scanner.Core/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } } \ No newline at end of file diff --git a/out/analyzers/rust/StellaOps.Auth.Abstractions.xml b/out/analyzers/rust/StellaOps.Auth.Abstractions.xml index 50110445..abe1bd26 100644 --- a/out/analyzers/rust/StellaOps.Auth.Abstractions.xml +++ b/out/analyzers/rust/StellaOps.Auth.Abstractions.xml @@ -1,767 +1,767 @@ - - - - StellaOps.Auth.Abstractions - - - - - Canonical telemetry metadata for the StellaOps Authority stack. - - - - - service.name resource attribute recorded by Authority components. - - - - - service.namespace resource attribute aligning Authority with other StellaOps services. - - - - - Activity source identifier used by Authority instrumentation. - - - - - Meter name used by Authority instrumentation. - - - - - Builds the default set of resource attributes (service name/namespace/version). - - Optional assembly used to resolve the service version. - - - - Resolves the service version string from the provided assembly (defaults to the Authority telemetry assembly). - - - - - Represents an IP network expressed in CIDR notation. - - - - - Initialises a new . - - Canonical network address with host bits zeroed. - Prefix length (0-32 for IPv4, 0-128 for IPv6). - - - - Canonical network address with host bits zeroed. - - - - - Prefix length. - - - - - Attempts to parse the supplied value as CIDR notation or a single IP address. - - Thrown when the input is not recognised. - - - - Attempts to parse the supplied value as CIDR notation or a single IP address. - - - - - Determines whether the provided address belongs to this network. - - - - - - - - Evaluates remote addresses against configured network masks. - - - - - Creates a matcher from raw CIDR strings. - - Sequence of CIDR entries or IP addresses. - Thrown when a value cannot be parsed. - - - - Creates a matcher from already parsed masks. - - Sequence of network masks. - - - - Gets a matcher that allows every address. - - - - - Gets a matcher that denies every address (no masks configured). - - - - - Indicates whether this matcher has no masks configured and does not allow all. - - - - - Returns the configured masks. - - - - - Checks whether the provided address matches any of the configured masks. - - Remote address to test. - true when the address is allowed. - - - - Default authentication constants used by StellaOps resource servers and clients. - - - - - Default authentication scheme for StellaOps bearer tokens. - - - - - Logical authentication type attached to . - - - - - Policy prefix applied to named authorization policies. - - - - - Canonical claim type identifiers used across StellaOps services. - - - - - Subject identifier claim (maps to sub in JWTs). - - - - - StellaOps tenant identifier claim (multi-tenant deployments). - - - - - StellaOps project identifier claim (optional project scoping within a tenant). - - - - - OAuth2/OIDC client identifier claim (maps to client_id). - - - - - Unique token identifier claim (maps to jti). - - - - - Authentication method reference claim (amr). - - - - - Space separated scope list (scope). - - - - - Individual scope items (scp). - - - - - OAuth2 resource audiences (aud). - - - - - Identity provider hint for downstream services. - - - - - Operator reason supplied when issuing orchestrator control tokens. - - - - - Operator ticket supplied when issuing orchestrator control tokens. - - - - - Quota change reason supplied when issuing Orchestrator quota tokens. - - - - - Quota change ticket/incident reference supplied when issuing Orchestrator quota tokens. - - - - - Incident activation reason recorded when issuing observability incident tokens. - - - - - Session identifier claim (sid). - - - - - Fluent helper used to construct instances that follow StellaOps conventions. - - - - - Adds or replaces the canonical subject identifier. - - - - - Adds or replaces the canonical client identifier. - - - - - Adds or replaces the tenant identifier claim. - - - - - Adds or replaces the user display name claim. - - - - - Adds or replaces the identity provider claim. - - - - - Adds or replaces the session identifier claim. - - - - - Adds or replaces the token identifier claim. - - - - - Adds or replaces the authentication method reference claim. - - - - - Sets the name claim type appended when building the . - - - - - Sets the role claim type appended when building the . - - - - - Sets the authentication type stamped on the . - - - - - Registers the supplied scopes (normalised to lower-case, deduplicated, sorted). - - - - - Registers the supplied audiences (trimmed, deduplicated, sorted). - - - - - Adds a single audience. - - - - - Adds an arbitrary claim (no deduplication is performed). - - - - - Adds multiple claims (incoming claims are cloned to enforce value trimming). - - - - - Adds an iat (issued at) claim using Unix time seconds. - - - - - Adds an nbf (not before) claim using Unix time seconds. - - - - - Adds an exp (expires) claim using Unix time seconds. - - - - - Returns the normalised scope list (deduplicated + sorted). - - - - - Returns the normalised audience list (deduplicated + sorted). - - - - - Builds the immutable instance based on the registered data. - - - - - Factory helpers for returning RFC 7807 problem responses using StellaOps conventions. - - - - - Produces a 401 problem response indicating authentication is required. - - - - - Produces a 401 problem response for invalid, expired, or revoked tokens. - - - - - Produces a 403 problem response when access is denied. - - - - - Produces a 403 problem response for insufficient scopes. - - - - - Canonical scope names supported by StellaOps services. - - - - - Scope required to trigger Concelier jobs. - - - - - Scope required to manage Concelier merge operations. - - - - - Scope granting administrative access to Authority user management. - - - - - Scope granting administrative access to Authority client registrations. - - - - - Scope granting read-only access to Authority audit logs. - - - - - Synthetic scope representing trusted network bypass. - - - - - Scope granting read-only access to console UX features. - - - - - Scope granting permission to approve exceptions. - - - - - Scope granting read-only access to raw advisory ingestion data. - - - - - Scope granting write access for raw advisory ingestion. - - - - - Scope granting read-only access to Advisory AI artefacts (summaries, remediation exports). - - - - - Scope permitting Advisory AI inference requests and workflow execution. - - - - - Scope granting administrative control over Advisory AI configuration and profiles. - - - - - Scope granting read-only access to raw VEX ingestion data. - - - - - Scope granting write access for raw VEX ingestion. - - - - - Scope granting permission to execute aggregation-only contract verification. - - - - - Scope granting read-only access to reachability signals. - - - - - Scope granting permission to write reachability signals. - - - - - Scope granting administrative access to reachability signal ingestion. - - - - - Scope granting permission to seal or unseal an installation in air-gapped mode. - - - - - Scope granting permission to import offline bundles while in air-gapped mode. - - - - - Scope granting read-only access to air-gap status and sealing state endpoints. - - - - - Scope granting permission to create or edit policy drafts. - - - - - Scope granting permission to author Policy Studio workspaces. - - - - - Scope granting permission to edit policy configurations. - - - - - Scope granting read-only access to policy metadata. - - - - - Scope granting permission to review Policy Studio drafts. - - - - - Scope granting permission to submit drafts for review. - - - - - Scope granting permission to approve or reject policies. - - - - - Scope granting permission to operate Policy Studio promotions and runs. - - - - - Scope granting permission to audit Policy Studio activity. - - - - - Scope granting permission to trigger policy runs and activation workflows. - - - - - Scope granting permission to activate policies. - - - - - Scope granting read-only access to effective findings materialised by Policy Engine. - - - - - Scope granting permission to run Policy Studio simulations. - - - - - Scope granted to Policy Engine service identity for writing effective findings. - - - - - Scope granting read-only access to graph queries and overlays. - - - - - Scope granting read-only access to Vuln Explorer resources and permalinks. - - - - - Scope granting read-only access to observability dashboards and overlays. - - - - - Scope granting read-only access to incident timelines and chronology data. - - - - - Scope granting permission to append events to incident timelines. - - - - - Scope granting permission to create evidence packets in the evidence locker. - - - - - Scope granting read-only access to stored evidence packets. - - - - - Scope granting permission to place or release legal holds on evidence packets. - - - - - Scope granting read-only access to attestation records and observer feeds. - - - - - Scope granting permission to activate or resolve observability incident mode controls. - - - - - Scope granting read-only access to export center runs and bundles. - - - - - Scope granting permission to operate export center scheduling and run execution. - - - - - Scope granting administrative control over export center retention, encryption keys, and scheduling policies. - - - - - Scope granting read-only access to notifier channels, rules, and delivery history. - - - - - Scope permitting notifier rule management, delivery actions, and channel operations. - - - - - Scope granting administrative control over notifier secrets, escalations, and platform-wide settings. - - - - - Scope granting read-only access to issuer directory catalogues. - - - - - Scope permitting creation and modification of issuer directory entries. - - - - - Scope granting administrative control over issuer directory resources (delete, audit bypass). - - - - - Scope required to issue or honour escalation actions for notifications. - - - - - Scope granting read-only access to Task Packs catalogues and manifests. - - - - - Scope permitting publication or updates to Task Packs in the registry. - - - - - Scope granting permission to execute Task Packs via CLI or Task Runner. - - - - - Scope granting permission to fulfil Task Pack approval gates. - - - - - Scope granting permission to enqueue or mutate graph build jobs. - - - - - Scope granting permission to export graph artefacts (GraphML/JSONL/etc.). - - - - - Scope granting permission to trigger what-if simulations on graphs. - - - - - Scope granting read-only access to Orchestrator job state and telemetry. - - - - - Scope granting permission to execute Orchestrator control actions. - - - - - Scope granting permission to manage Orchestrator quotas and elevated backfill tooling. - - - - - Scope granting read-only access to Authority tenant catalog APIs. - - - - - Normalises a scope string (trim/convert to lower case). - - Scope raw value. - Normalised scope or null when the input is blank. - - - - Checks whether the provided scope is registered as a built-in StellaOps scope. - - - - - Returns the full set of built-in scopes. - - - - - Canonical identifiers for StellaOps service principals. - - - - - Service identity used by Policy Engine when materialising effective findings. - - - - - Service identity used by Cartographer when constructing and maintaining graph projections. - - - - - Service identity used by Vuln Explorer when issuing scoped permalink requests. - - - - - Service identity used by Signals components when managing reachability facts. - - - - - Shared tenancy default values used across StellaOps services. - - - - - Sentinel value indicating the token is not scoped to a specific project. - - - - + + + + StellaOps.Auth.Abstractions + + + + + Canonical telemetry metadata for the StellaOps Authority stack. + + + + + service.name resource attribute recorded by Authority components. + + + + + service.namespace resource attribute aligning Authority with other StellaOps services. + + + + + Activity source identifier used by Authority instrumentation. + + + + + Meter name used by Authority instrumentation. + + + + + Builds the default set of resource attributes (service name/namespace/version). + + Optional assembly used to resolve the service version. + + + + Resolves the service version string from the provided assembly (defaults to the Authority telemetry assembly). + + + + + Represents an IP network expressed in CIDR notation. + + + + + Initialises a new . + + Canonical network address with host bits zeroed. + Prefix length (0-32 for IPv4, 0-128 for IPv6). + + + + Canonical network address with host bits zeroed. + + + + + Prefix length. + + + + + Attempts to parse the supplied value as CIDR notation or a single IP address. + + Thrown when the input is not recognised. + + + + Attempts to parse the supplied value as CIDR notation or a single IP address. + + + + + Determines whether the provided address belongs to this network. + + + + + + + + Evaluates remote addresses against configured network masks. + + + + + Creates a matcher from raw CIDR strings. + + Sequence of CIDR entries or IP addresses. + Thrown when a value cannot be parsed. + + + + Creates a matcher from already parsed masks. + + Sequence of network masks. + + + + Gets a matcher that allows every address. + + + + + Gets a matcher that denies every address (no masks configured). + + + + + Indicates whether this matcher has no masks configured and does not allow all. + + + + + Returns the configured masks. + + + + + Checks whether the provided address matches any of the configured masks. + + Remote address to test. + true when the address is allowed. + + + + Default authentication constants used by StellaOps resource servers and clients. + + + + + Default authentication scheme for StellaOps bearer tokens. + + + + + Logical authentication type attached to . + + + + + Policy prefix applied to named authorization policies. + + + + + Canonical claim type identifiers used across StellaOps services. + + + + + Subject identifier claim (maps to sub in JWTs). + + + + + StellaOps tenant identifier claim (multi-tenant deployments). + + + + + StellaOps project identifier claim (optional project scoping within a tenant). + + + + + OAuth2/OIDC client identifier claim (maps to client_id). + + + + + Unique token identifier claim (maps to jti). + + + + + Authentication method reference claim (amr). + + + + + Space separated scope list (scope). + + + + + Individual scope items (scp). + + + + + OAuth2 resource audiences (aud). + + + + + Identity provider hint for downstream services. + + + + + Operator reason supplied when issuing orchestrator control tokens. + + + + + Operator ticket supplied when issuing orchestrator control tokens. + + + + + Quota change reason supplied when issuing Orchestrator quota tokens. + + + + + Quota change ticket/incident reference supplied when issuing Orchestrator quota tokens. + + + + + Incident activation reason recorded when issuing observability incident tokens. + + + + + Session identifier claim (sid). + + + + + Fluent helper used to construct instances that follow StellaOps conventions. + + + + + Adds or replaces the canonical subject identifier. + + + + + Adds or replaces the canonical client identifier. + + + + + Adds or replaces the tenant identifier claim. + + + + + Adds or replaces the user display name claim. + + + + + Adds or replaces the identity provider claim. + + + + + Adds or replaces the session identifier claim. + + + + + Adds or replaces the token identifier claim. + + + + + Adds or replaces the authentication method reference claim. + + + + + Sets the name claim type appended when building the . + + + + + Sets the role claim type appended when building the . + + + + + Sets the authentication type stamped on the . + + + + + Registers the supplied scopes (normalised to lower-case, deduplicated, sorted). + + + + + Registers the supplied audiences (trimmed, deduplicated, sorted). + + + + + Adds a single audience. + + + + + Adds an arbitrary claim (no deduplication is performed). + + + + + Adds multiple claims (incoming claims are cloned to enforce value trimming). + + + + + Adds an iat (issued at) claim using Unix time seconds. + + + + + Adds an nbf (not before) claim using Unix time seconds. + + + + + Adds an exp (expires) claim using Unix time seconds. + + + + + Returns the normalised scope list (deduplicated + sorted). + + + + + Returns the normalised audience list (deduplicated + sorted). + + + + + Builds the immutable instance based on the registered data. + + + + + Factory helpers for returning RFC 7807 problem responses using StellaOps conventions. + + + + + Produces a 401 problem response indicating authentication is required. + + + + + Produces a 401 problem response for invalid, expired, or revoked tokens. + + + + + Produces a 403 problem response when access is denied. + + + + + Produces a 403 problem response for insufficient scopes. + + + + + Canonical scope names supported by StellaOps services. + + + + + Scope required to trigger Concelier jobs. + + + + + Scope required to manage Concelier merge operations. + + + + + Scope granting administrative access to Authority user management. + + + + + Scope granting administrative access to Authority client registrations. + + + + + Scope granting read-only access to Authority audit logs. + + + + + Synthetic scope representing trusted network bypass. + + + + + Scope granting read-only access to console UX features. + + + + + Scope granting permission to approve exceptions. + + + + + Scope granting read-only access to raw advisory ingestion data. + + + + + Scope granting write access for raw advisory ingestion. + + + + + Scope granting read-only access to Advisory AI artefacts (summaries, remediation exports). + + + + + Scope permitting Advisory AI inference requests and workflow execution. + + + + + Scope granting administrative control over Advisory AI configuration and profiles. + + + + + Scope granting read-only access to raw VEX ingestion data. + + + + + Scope granting write access for raw VEX ingestion. + + + + + Scope granting permission to execute aggregation-only contract verification. + + + + + Scope granting read-only access to reachability signals. + + + + + Scope granting permission to write reachability signals. + + + + + Scope granting administrative access to reachability signal ingestion. + + + + + Scope granting permission to seal or unseal an installation in air-gapped mode. + + + + + Scope granting permission to import offline bundles while in air-gapped mode. + + + + + Scope granting read-only access to air-gap status and sealing state endpoints. + + + + + Scope granting permission to create or edit policy drafts. + + + + + Scope granting permission to author Policy Studio workspaces. + + + + + Scope granting permission to edit policy configurations. + + + + + Scope granting read-only access to policy metadata. + + + + + Scope granting permission to review Policy Studio drafts. + + + + + Scope granting permission to submit drafts for review. + + + + + Scope granting permission to approve or reject policies. + + + + + Scope granting permission to operate Policy Studio promotions and runs. + + + + + Scope granting permission to audit Policy Studio activity. + + + + + Scope granting permission to trigger policy runs and activation workflows. + + + + + Scope granting permission to activate policies. + + + + + Scope granting read-only access to effective findings materialised by Policy Engine. + + + + + Scope granting permission to run Policy Studio simulations. + + + + + Scope granted to Policy Engine service identity for writing effective findings. + + + + + Scope granting read-only access to graph queries and overlays. + + + + + Scope granting read-only access to Vuln Explorer resources and permalinks. + + + + + Scope granting read-only access to observability dashboards and overlays. + + + + + Scope granting read-only access to incident timelines and chronology data. + + + + + Scope granting permission to append events to incident timelines. + + + + + Scope granting permission to create evidence packets in the evidence locker. + + + + + Scope granting read-only access to stored evidence packets. + + + + + Scope granting permission to place or release legal holds on evidence packets. + + + + + Scope granting read-only access to attestation records and observer feeds. + + + + + Scope granting permission to activate or resolve observability incident mode controls. + + + + + Scope granting read-only access to export center runs and bundles. + + + + + Scope granting permission to operate export center scheduling and run execution. + + + + + Scope granting administrative control over export center retention, encryption keys, and scheduling policies. + + + + + Scope granting read-only access to notifier channels, rules, and delivery history. + + + + + Scope permitting notifier rule management, delivery actions, and channel operations. + + + + + Scope granting administrative control over notifier secrets, escalations, and platform-wide settings. + + + + + Scope granting read-only access to issuer directory catalogues. + + + + + Scope permitting creation and modification of issuer directory entries. + + + + + Scope granting administrative control over issuer directory resources (delete, audit bypass). + + + + + Scope required to issue or honour escalation actions for notifications. + + + + + Scope granting read-only access to Task Packs catalogues and manifests. + + + + + Scope permitting publication or updates to Task Packs in the registry. + + + + + Scope granting permission to execute Task Packs via CLI or Task Runner. + + + + + Scope granting permission to fulfil Task Pack approval gates. + + + + + Scope granting permission to enqueue or mutate graph build jobs. + + + + + Scope granting permission to export graph artefacts (GraphML/JSONL/etc.). + + + + + Scope granting permission to trigger what-if simulations on graphs. + + + + + Scope granting read-only access to Orchestrator job state and telemetry. + + + + + Scope granting permission to execute Orchestrator control actions. + + + + + Scope granting permission to manage Orchestrator quotas and elevated backfill tooling. + + + + + Scope granting read-only access to Authority tenant catalog APIs. + + + + + Normalises a scope string (trim/convert to lower case). + + Scope raw value. + Normalised scope or null when the input is blank. + + + + Checks whether the provided scope is registered as a built-in StellaOps scope. + + + + + Returns the full set of built-in scopes. + + + + + Canonical identifiers for StellaOps service principals. + + + + + Service identity used by Policy Engine when materialising effective findings. + + + + + Service identity used by Cartographer when constructing and maintaining graph projections. + + + + + Service identity used by Vuln Explorer when issuing scoped permalink requests. + + + + + Service identity used by Signals components when managing reachability facts. + + + + + Shared tenancy default values used across StellaOps services. + + + + + Sentinel value indicating the token is not scoped to a specific project. + + + + diff --git a/out/analyzers/rust/StellaOps.Auth.Client.xml b/out/analyzers/rust/StellaOps.Auth.Client.xml index cd693458..30a8915a 100644 --- a/out/analyzers/rust/StellaOps.Auth.Client.xml +++ b/out/analyzers/rust/StellaOps.Auth.Client.xml @@ -1,233 +1,233 @@ - - - - StellaOps.Auth.Client - - - - - File-based token cache suitable for CLI/offline usage. - - - - - In-memory token cache suitable for service scenarios. - - - - - Abstraction for caching StellaOps tokens. - - - - - Retrieves a cached token entry, if present. - - - - - Stores or updates a token entry for the specified key. - - - - - Removes the cached entry for the specified key. - - - - - Abstraction for requesting tokens from StellaOps Authority. - - - - - Requests an access token using the resource owner password credentials flow. - - - - - Requests an access token using the client credentials flow. - - - - - Retrieves the cached JWKS document. - - - - - Retrieves a cached token entry. - - - - - Persists a token entry in the cache. - - - - - Removes a cached entry. - - - - - DI helpers for the StellaOps auth client. - - - - - Registers the StellaOps auth client with the provided configuration. - - - - - Registers a file-backed token cache implementation. - - - - - Options controlling the StellaOps authentication client. - - - - - Authority (issuer) base URL. - - - - - OAuth client identifier (optional for password flow). - - - - - OAuth client secret (optional for public clients). - - - - - Default scopes requested for flows that do not explicitly override them. - - - - - Retry delays applied by HTTP retry policy (empty uses defaults). - - - - - Gets or sets a value indicating whether HTTP retry policies are enabled. - - - - - Timeout applied to discovery and token HTTP requests. - - - - - Lifetime of cached discovery metadata. - - - - - Lifetime of cached JWKS metadata. - - - - - Buffer applied when determining cache expiration (default: 30 seconds). - - - - - Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. - - - - - Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. - - - - - Parsed Authority URI (populated after validation). - - - - - Normalised scope list (populated after validation). - - - - - Normalised retry delays (populated after validation). - - - - - Validates required values and normalises scope entries. - - - - - Caches Authority discovery metadata. - - - - - Minimal OpenID Connect configuration representation. - - - - - Minimal OpenID Connect configuration representation. - - - - - Caches JWKS documents for Authority. - - - - - Represents a cached token entry. - - - - - Represents a cached token entry. - - - - - Determines whether the token is expired given the provided . - - - - - Creates a copy with scopes normalised. - - - - - Default implementation of . - - - - - Represents an issued token with metadata. - - - - - Represents an issued token with metadata. - - - - - Converts the result to a cache entry. - - - - + + + + StellaOps.Auth.Client + + + + + File-based token cache suitable for CLI/offline usage. + + + + + In-memory token cache suitable for service scenarios. + + + + + Abstraction for caching StellaOps tokens. + + + + + Retrieves a cached token entry, if present. + + + + + Stores or updates a token entry for the specified key. + + + + + Removes the cached entry for the specified key. + + + + + Abstraction for requesting tokens from StellaOps Authority. + + + + + Requests an access token using the resource owner password credentials flow. + + + + + Requests an access token using the client credentials flow. + + + + + Retrieves the cached JWKS document. + + + + + Retrieves a cached token entry. + + + + + Persists a token entry in the cache. + + + + + Removes a cached entry. + + + + + DI helpers for the StellaOps auth client. + + + + + Registers the StellaOps auth client with the provided configuration. + + + + + Registers a file-backed token cache implementation. + + + + + Options controlling the StellaOps authentication client. + + + + + Authority (issuer) base URL. + + + + + OAuth client identifier (optional for password flow). + + + + + OAuth client secret (optional for public clients). + + + + + Default scopes requested for flows that do not explicitly override them. + + + + + Retry delays applied by HTTP retry policy (empty uses defaults). + + + + + Gets or sets a value indicating whether HTTP retry policies are enabled. + + + + + Timeout applied to discovery and token HTTP requests. + + + + + Lifetime of cached discovery metadata. + + + + + Lifetime of cached JWKS metadata. + + + + + Buffer applied when determining cache expiration (default: 30 seconds). + + + + + Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. + + + + + Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. + + + + + Parsed Authority URI (populated after validation). + + + + + Normalised scope list (populated after validation). + + + + + Normalised retry delays (populated after validation). + + + + + Validates required values and normalises scope entries. + + + + + Caches Authority discovery metadata. + + + + + Minimal OpenID Connect configuration representation. + + + + + Minimal OpenID Connect configuration representation. + + + + + Caches JWKS documents for Authority. + + + + + Represents a cached token entry. + + + + + Represents a cached token entry. + + + + + Determines whether the token is expired given the provided . + + + + + Creates a copy with scopes normalised. + + + + + Default implementation of . + + + + + Represents an issued token with metadata. + + + + + Represents an issued token with metadata. + + + + + Converts the result to a cache entry. + + + + diff --git a/out/analyzers/rust/StellaOps.Scanner.Analyzers.Lang.Rust.deps.json b/out/analyzers/rust/StellaOps.Scanner.Analyzers.Lang.Rust.deps.json index 6e1d9635..00631c7b 100644 --- a/out/analyzers/rust/StellaOps.Scanner.Analyzers.Lang.Rust.deps.json +++ b/out/analyzers/rust/StellaOps.Scanner.Analyzers.Lang.Rust.deps.json @@ -1,858 +1,858 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v10.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v10.0": { - "StellaOps.Scanner.Analyzers.Lang.Rust/1.0.0": { - "dependencies": { - "SharpCompress": "0.41.0", - "StellaOps.Scanner.Analyzers.Lang": "1.0.0" - }, - "runtime": { - "StellaOps.Scanner.Analyzers.Lang.Rust.dll": {} - } - }, - "Konscious.Security.Cryptography.Argon2/1.3.1": { - "dependencies": { - "Konscious.Security.Cryptography.Blake2": "1.1.1" - }, - "runtime": { - "lib/net8.0/Konscious.Security.Cryptography.Argon2.dll": { - "assemblyVersion": "1.3.1.0", - "fileVersion": "1.3.1.0" - } - } - }, - "Konscious.Security.Cryptography.Blake2/1.1.1": { - "runtime": { - "lib/net8.0/Konscious.Security.Cryptography.Blake2.dll": { - "assemblyVersion": "1.1.1.0", - "fileVersion": "1.1.1.0" - } - } - }, - "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Configuration.Json.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.DependencyInjection.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Diagnostics.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileProviders.Physical.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Diagnostics": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Http.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Http": "10.0.0-rc.2.25502.107", - "Polly": "7.2.4", - "Polly.Extensions.Http": "3.0.0" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.Extensions.Http.Polly.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Logging.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Options.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" - }, - "runtime": { - "lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { - "runtime": { - "lib/net10.0/Microsoft.Extensions.Primitives.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "10.0.25.50307" - } - } - }, - "Microsoft.IdentityModel.Abstractions/8.14.0": { - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0" - }, - "runtime": { - "lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": { - "assemblyVersion": "7.2.0.0", - "fileVersion": "7.2.0.50110" - } - } - }, - "Microsoft.IdentityModel.Logging/8.14.0": { - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0" - }, - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Logging.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "Microsoft.IdentityModel.Tokens/8.14.0": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.IdentityModel.Logging": "8.14.0" - }, - "runtime": { - "lib/net9.0/Microsoft.IdentityModel.Tokens.dll": { - "assemblyVersion": "8.14.0.0", - "fileVersion": "8.14.0.60815" - } - } - }, - "NetEscapades.Configuration.Yaml/2.1.0": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "YamlDotNet": "9.1.0" - }, - "runtime": { - "lib/netstandard2.0/NetEscapades.Configuration.Yaml.dll": { - "assemblyVersion": "2.1.0.0", - "fileVersion": "2.1.0.0" - } - } - }, - "Pipelines.Sockets.Unofficial/2.2.8": { - "runtime": { - "lib/net5.0/Pipelines.Sockets.Unofficial.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "2.2.8.1080" - } - } - }, - "Polly/7.2.4": { - "runtime": { - "lib/netstandard2.0/Polly.dll": { - "assemblyVersion": "7.0.0.0", - "fileVersion": "7.2.4.982" - } - } - }, - "Polly.Extensions.Http/3.0.0": { - "dependencies": { - "Polly": "7.2.4" - }, - "runtime": { - "lib/netstandard2.0/Polly.Extensions.Http.dll": { - "assemblyVersion": "3.0.0.0", - "fileVersion": "3.0.0.0" - } - } - }, - "SharpCompress/0.41.0": { - "dependencies": { - "ZstdSharp.Port": "0.8.6" - }, - "runtime": { - "lib/net8.0/SharpCompress.dll": { - "assemblyVersion": "0.41.0.0", - "fileVersion": "0.41.0.0" - } - } - }, - "StackExchange.Redis/2.8.24": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Pipelines.Sockets.Unofficial": "2.2.8" - }, - "runtime": { - "lib/net8.0/StackExchange.Redis.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.8.24.3255" - } - } - }, - "System.IdentityModel.Tokens.Jwt/7.2.0": { - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "7.2.0", - "Microsoft.IdentityModel.Tokens": "8.14.0" - }, - "runtime": { - "lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": { - "assemblyVersion": "7.2.0.0", - "fileVersion": "7.2.0.50110" - } - } - }, - "YamlDotNet/9.1.0": { - "runtime": { - "lib/netstandard2.1/YamlDotNet.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.1.0.0" - } - } - }, - "ZstdSharp.Port/0.8.6": { - "runtime": { - "lib/net9.0/ZstdSharp.dll": { - "assemblyVersion": "0.8.6.0", - "fileVersion": "0.8.6.0" - } - } - }, - "StellaOps.Auth.Abstractions/1.0.0-preview.1": { - "dependencies": { - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.Auth.Abstractions.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Auth.Client/1.0.0-preview.1": { - "dependencies": { - "Microsoft.Extensions.Http.Polly": "10.0.0-rc.2.25502.107", - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Abstractions": "1.0.0-preview.1", - "StellaOps.Configuration": "1.0.0" - }, - "runtime": { - "StellaOps.Auth.Client.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Auth.Security/1.0.0-preview.1": { - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0", - "StackExchange.Redis": "2.8.24", - "System.IdentityModel.Tokens.Jwt": "7.2.0" - }, - "runtime": { - "StellaOps.Auth.Security.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Authority.Plugins.Abstractions/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Abstractions": "1.0.0-preview.1", - "StellaOps.Cryptography": "1.0.0" - }, - "runtime": { - "StellaOps.Authority.Plugins.Abstractions.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Configuration/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Configuration.Json": "10.0.0-rc.2.25502.107", - "NetEscapades.Configuration.Yaml": "2.1.0", - "SharpCompress": "0.41.0", - "StellaOps.Authority.Plugins.Abstractions": "1.0.0", - "StellaOps.Cryptography": "1.0.0" - }, - "runtime": { - "StellaOps.Configuration.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Cryptography/1.0.0": { - "dependencies": { - "Konscious.Security.Cryptography.Argon2": "1.3.1", - "Microsoft.IdentityModel.Tokens": "8.14.0", - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.Cryptography.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.DependencyInjection/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0" - }, - "runtime": { - "StellaOps.DependencyInjection.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Plugin/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.DependencyInjection": "1.0.0" - }, - "runtime": { - "StellaOps.Plugin.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Scanner.Analyzers.Lang/1.0.0": { - "dependencies": { - "SharpCompress": "0.41.0", - "StellaOps.Plugin": "1.0.0", - "StellaOps.Scanner.Core": "1.0.0" - }, - "runtime": { - "StellaOps.Scanner.Analyzers.Lang.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "StellaOps.Scanner.Core/1.0.0": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", - "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", - "SharpCompress": "0.41.0", - "StellaOps.Auth.Client": "1.0.0-preview.1", - "StellaOps.Auth.Security": "1.0.0-preview.1" - }, - "runtime": { - "StellaOps.Scanner.Core.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - } - } - }, - "libraries": { - "StellaOps.Scanner.Analyzers.Lang.Rust/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Konscious.Security.Cryptography.Argon2/1.3.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-T+OAGwzYYXftahpOxO7J4xA5K6urxwGnWQf3M+Jpi+76Azv/0T3M5SuN+h7/QvXuiqNw3ZEZ5QqVLI5ygDAylw==", - "path": "konscious.security.cryptography.argon2/1.3.1", - "hashPath": "konscious.security.cryptography.argon2.1.3.1.nupkg.sha512" - }, - "Konscious.Security.Cryptography.Blake2/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-odwOyzj/J/lHJZNwFWJGU/LRecBShupAJ2S8TQqZfhUe9niHzu/voBYK5wuVKsvSpzbfupKQYZguVyIk1sgOkQ==", - "path": "konscious.security.cryptography.blake2/1.1.1", - "hashPath": "konscious.security.cryptography.blake2.1.1.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-273Ggibh3DdVrj47ENbUGIirOiqmLTAizpkvOD584Ps6NL/CMXPzesijnJgsjp7Fv/UCp69FKYBaSxZZ3q5R9g==", - "path": "microsoft.extensions.configuration/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-H+i/Qy30Rg/K9BcW2Z6DCHPCzwMH3bCwNOjEz31shWTUDK8GeeeMnrKVusprTcRA2Y6yPST+hg2zc3whPEs14Q==", - "path": "microsoft.extensions.configuration.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-aA6/V6lw1Gueyb1PqhHAl/i/qUUuv+Fusfk4oaMOzzOjspBkYtPpNHCmml/0t1x0/DnZoed+u2WwpP+mSwd8Dg==", - "path": "microsoft.extensions.configuration.binder/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.binder.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-2SV60IUAWfluZv2YHNZ+nUOljYHGIsy96FpJs+N9/bgKDYs9qr6DdzPeIhiHrz+XvRzbybvcwtTBf5dKrYN4oA==", - "path": "microsoft.extensions.configuration.environmentvariables/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.environmentvariables.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5KrgXSTFR8cFLmDXXoT7GLVvDyHNw0Z9xG4doD78Q/HdlAR4jiMzmLLS9GFXrPGopmC6qqEZr2VBJHEu16INcA==", - "path": "microsoft.extensions.configuration.fileextensions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.fileextensions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-USwHuFz4BFKoaqSydHWH/d7Mr+fVsAh9S0S9pdsdHro1IixMbqQ9Gpo2sEZf25e3tZSq/ts6XsVmrQWmxmDhYA==", - "path": "microsoft.extensions.configuration.json/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.configuration.json.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-mDw80K98jBWCyLFCra51PRv+Ttnjse1lZIzXEFybKby0/ajBFTEeHj/4r/QJexmb8Uun0yaFH1HlFtmHP1YEVA==", - "path": "microsoft.extensions.dependencyinjection/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.dependencyinjection.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-8jujunpkNNfTkE9PFHp9/aD6GPKVfNCuz8tUbzOcyU5tQOCoIZId4hwQNVx3Tb8XEWw9BYdh0k5vPpqdCM+UtA==", - "path": "microsoft.extensions.dependencyinjection.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-tQfQFXI+ZQcL2RzDarDLx3Amh0WCp1KPGp1ie3y/CMV5hDhEq98WTmcMoXrFY0GkYLEaCQlVi2A6qVLcooG2Ow==", - "path": "microsoft.extensions.diagnostics/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.diagnostics.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-x6XVv3RiwOlN2unjyX/Zat0gI0HiRoDDdjkwBCwsMftYWpbJu4SiyRwDbrv2zAF8v8nbEEvcWi3/pUxZfaqLQw==", - "path": "microsoft.extensions.diagnostics.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.diagnostics.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dOpmW14MkOZIwV6269iXhoMp6alCHBoxqCR4pJ37GLjFaBIyzsIy+Ra8tsGmjHtFvEHKq0JRDIsb1PUkrK+yxw==", - "path": "microsoft.extensions.fileproviders.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.fileproviders.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-3+RiR6TEakDL0dCUqR7PjFffyrVMLdx/vAVBiN1mGmwScKYCTePIkYVkWsX85CTKh7R9J4M9C1MHzVdjbKcg3g==", - "path": "microsoft.extensions.fileproviders.physical/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.fileproviders.physical.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XtcPOKB7sMFzj8SxaOglZV3eaqZ1GxUMVZTwaz4pRpBt0S45ghb836uUej4YaI8EzsnUJoqzOIKrTW4CDJMfVw==", - "path": "microsoft.extensions.filesystemglobbing/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.filesystemglobbing.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-d60bvi/NpzkpVlSpxZqOfdjX1hrQgL/byWVc3PryjbmB7zvfLtqQbYifjEWToqtS0Fb1rGnkuVI5JEdOnK1tNQ==", - "path": "microsoft.extensions.http/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.http.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-aY5vLcrhdXCHsCjYI2lNwfat2vdSuiPs0FFZiy7IM6zcyqdxaefG8J8ezTKkZyiuAtznjVJJT70B660l/WlsxA==", - "path": "microsoft.extensions.http.polly/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.http.polly.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-q2C5gq86qkTmcYSJJSnw8sgTUyuqENYSOjk/NOYjHnYlKSrK3oI9Rjv1bWFpx2I3Btq9ZBEJb9aMM+IUQ0PvZA==", - "path": "microsoft.extensions.logging/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.logging.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-SKKKZjyCpBaDQ7yuFjdk6ELnRBRWeZsbnzUfo59Wc4PGhgf92chE3we/QlT6nk6NqlWcUgH/jogM+B/uq/Qdnw==", - "path": "microsoft.extensions.logging.abstractions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.logging.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Ib6BCCjisp7ZUdhtNpSulFO0ODhz/IE4ZZd8OCqQWoRs363BQ0QOZi9KwpqpiEWo51S0kIXWqNicDPGXwpt9pQ==", - "path": "microsoft.extensions.options/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.options.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-MFbT8+JKX49YCXEFvlZDzQzI/R3QKzRZlb4dSud+569cMgA9hWbndjWWvOgGASoRcXynGRrBSq1Bw3PeCsB5/Q==", - "path": "microsoft.extensions.options.configurationextensions/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.options.configurationextensions.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { - "type": "package", - "serviceable": true, - "sha512": "sha512-9pm2zqqn5u/OsKs2zgkhJEQQeMx9KkVOWPdHrs7Kt5sfpk+eIh/gmpi/mMH/ljS2T/PFsFdCEtm+GS/6l7zoZA==", - "path": "microsoft.extensions.primitives/10.0.0-rc.2.25502.107", - "hashPath": "microsoft.extensions.primitives.10.0.0-rc.2.25502.107.nupkg.sha512" - }, - "Microsoft.IdentityModel.Abstractions/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==", - "path": "microsoft.identitymodel.abstractions/8.14.0", - "hashPath": "microsoft.identitymodel.abstractions.8.14.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-zLFA9IBxDWw6Y1nz2PPZyQvF+ZZ4aW1pwgtwusQB39lgxOc2xVqZ8gitsuT1rwyuIbchGOWbax4fsJ8OgGRxSQ==", - "path": "microsoft.identitymodel.jsonwebtokens/7.2.0", - "hashPath": "microsoft.identitymodel.jsonwebtokens.7.2.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.Logging/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", - "path": "microsoft.identitymodel.logging/8.14.0", - "hashPath": "microsoft.identitymodel.logging.8.14.0.nupkg.sha512" - }, - "Microsoft.IdentityModel.Tokens/8.14.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ySPkj429HrYHvwLVNoPZdQ/bKZZKSkuWKod68qxo+5/pLdXFimgflckKgAZclX9tuO9qWk/KFiIN65diMWgh+g==", - "path": "microsoft.identitymodel.tokens/8.14.0", - "hashPath": "microsoft.identitymodel.tokens.8.14.0.nupkg.sha512" - }, - "NetEscapades.Configuration.Yaml/2.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-kNTX7kvRvbzBpLd3Vg9iu6t60tTyhVxsruAPgH6kl1GkAZIHLZw9cQysvjUenDU7JEnUgyxQnzfL8627ARDn+g==", - "path": "netescapades.configuration.yaml/2.1.0", - "hashPath": "netescapades.configuration.yaml.2.1.0.nupkg.sha512" - }, - "Pipelines.Sockets.Unofficial/2.2.8": { - "type": "package", - "serviceable": true, - "sha512": "sha512-zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", - "path": "pipelines.sockets.unofficial/2.2.8", - "hashPath": "pipelines.sockets.unofficial.2.2.8.nupkg.sha512" - }, - "Polly/7.2.4": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bw00Ck5sh6ekduDE3mnCo1ohzuad946uslCDEENu3091+6UKnBuKLo4e+yaNcCzXxOZCXWY2gV4a35+K1d4LDA==", - "path": "polly/7.2.4", - "hashPath": "polly.7.2.4.nupkg.sha512" - }, - "Polly.Extensions.Http/3.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", - "path": "polly.extensions.http/3.0.0", - "hashPath": "polly.extensions.http.3.0.0.nupkg.sha512" - }, - "SharpCompress/0.41.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-z04dBVdTIAFTRKi38f0LkajaKA++bR+M8kYCbasXePILD2H+qs7CkLpyiippB24CSbTrWIgpBKm6BenZqkUwvw==", - "path": "sharpcompress/0.41.0", - "hashPath": "sharpcompress.0.41.0.nupkg.sha512" - }, - "StackExchange.Redis/2.8.24": { - "type": "package", - "serviceable": true, - "sha512": "sha512-GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", - "path": "stackexchange.redis/2.8.24", - "hashPath": "stackexchange.redis.2.8.24.nupkg.sha512" - }, - "System.IdentityModel.Tokens.Jwt/7.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Z3Fmkrxkp+o51ANMO/PqASRRlEz8dH4mTWwZXMFMXZt2bUGztBiNcIDnwBCElYLYpzpmz4sIqHb6aW8QVLe6YQ==", - "path": "system.identitymodel.tokens.jwt/7.2.0", - "hashPath": "system.identitymodel.tokens.jwt.7.2.0.nupkg.sha512" - }, - "YamlDotNet/9.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-fuvGXU4Ec5HrsmEc+BiFTNPCRf1cGBI2kh/3RzMWgddM2M4ALhbSPoI3X3mhXZUD1qqQd9oSkFAtWjpz8z9eRg==", - "path": "yamldotnet/9.1.0", - "hashPath": "yamldotnet.9.1.0.nupkg.sha512" - }, - "ZstdSharp.Port/0.8.6": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iP4jVLQoQmUjMU88g1WObiNr6YKZGvh4aOXn3yOJsHqZsflwRsxZPcIBvNXgjXO3vQKSLctXGLTpcBPLnWPS8A==", - "path": "zstdsharp.port/0.8.6", - "hashPath": "zstdsharp.port.0.8.6.nupkg.sha512" - }, - "StellaOps.Auth.Abstractions/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Auth.Client/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Auth.Security/1.0.0-preview.1": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Authority.Plugins.Abstractions/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Configuration/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Cryptography/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.DependencyInjection/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Plugin/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Scanner.Analyzers.Lang/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "StellaOps.Scanner.Core/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - } - } +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "StellaOps.Scanner.Analyzers.Lang.Rust/1.0.0": { + "dependencies": { + "SharpCompress": "0.41.0", + "StellaOps.Scanner.Analyzers.Lang": "1.0.0" + }, + "runtime": { + "StellaOps.Scanner.Analyzers.Lang.Rust.dll": {} + } + }, + "Konscious.Security.Cryptography.Argon2/1.3.1": { + "dependencies": { + "Konscious.Security.Cryptography.Blake2": "1.1.1" + }, + "runtime": { + "lib/net8.0/Konscious.Security.Cryptography.Argon2.dll": { + "assemblyVersion": "1.3.1.0", + "fileVersion": "1.3.1.0" + } + } + }, + "Konscious.Security.Cryptography.Blake2/1.1.1": { + "runtime": { + "lib/net8.0/Konscious.Security.Cryptography.Blake2.dll": { + "assemblyVersion": "1.1.1.0", + "fileVersion": "1.1.1.0" + } + } + }, + "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Configuration.Json.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.DependencyInjection.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Diagnostics.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileProviders.Physical.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Diagnostics": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Http.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Http": "10.0.0-rc.2.25502.107", + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.Extensions.Http.Polly.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Logging.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Options.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Primitives": "10.0.0-rc.2.25502.107" + }, + "runtime": { + "lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { + "runtime": { + "lib/net10.0/Microsoft.Extensions.Primitives.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "10.0.25.50307" + } + } + }, + "Microsoft.IdentityModel.Abstractions/8.14.0": { + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.14.0" + }, + "runtime": { + "lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": { + "assemblyVersion": "7.2.0.0", + "fileVersion": "7.2.0.50110" + } + } + }, + "Microsoft.IdentityModel.Logging/8.14.0": { + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0" + }, + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Logging.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "Microsoft.IdentityModel.Tokens/8.14.0": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.IdentityModel.Logging": "8.14.0" + }, + "runtime": { + "lib/net9.0/Microsoft.IdentityModel.Tokens.dll": { + "assemblyVersion": "8.14.0.0", + "fileVersion": "8.14.0.60815" + } + } + }, + "NetEscapades.Configuration.Yaml/2.1.0": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "YamlDotNet": "9.1.0" + }, + "runtime": { + "lib/netstandard2.0/NetEscapades.Configuration.Yaml.dll": { + "assemblyVersion": "2.1.0.0", + "fileVersion": "2.1.0.0" + } + } + }, + "Pipelines.Sockets.Unofficial/2.2.8": { + "runtime": { + "lib/net5.0/Pipelines.Sockets.Unofficial.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "2.2.8.1080" + } + } + }, + "Polly/7.2.4": { + "runtime": { + "lib/netstandard2.0/Polly.dll": { + "assemblyVersion": "7.0.0.0", + "fileVersion": "7.2.4.982" + } + } + }, + "Polly.Extensions.Http/3.0.0": { + "dependencies": { + "Polly": "7.2.4" + }, + "runtime": { + "lib/netstandard2.0/Polly.Extensions.Http.dll": { + "assemblyVersion": "3.0.0.0", + "fileVersion": "3.0.0.0" + } + } + }, + "SharpCompress/0.41.0": { + "dependencies": { + "ZstdSharp.Port": "0.8.6" + }, + "runtime": { + "lib/net8.0/SharpCompress.dll": { + "assemblyVersion": "0.41.0.0", + "fileVersion": "0.41.0.0" + } + } + }, + "StackExchange.Redis/2.8.24": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Pipelines.Sockets.Unofficial": "2.2.8" + }, + "runtime": { + "lib/net8.0/StackExchange.Redis.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.8.24.3255" + } + } + }, + "System.IdentityModel.Tokens.Jwt/7.2.0": { + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.2.0", + "Microsoft.IdentityModel.Tokens": "8.14.0" + }, + "runtime": { + "lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": { + "assemblyVersion": "7.2.0.0", + "fileVersion": "7.2.0.50110" + } + } + }, + "YamlDotNet/9.1.0": { + "runtime": { + "lib/netstandard2.1/YamlDotNet.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.1.0.0" + } + } + }, + "ZstdSharp.Port/0.8.6": { + "runtime": { + "lib/net9.0/ZstdSharp.dll": { + "assemblyVersion": "0.8.6.0", + "fileVersion": "0.8.6.0" + } + } + }, + "StellaOps.Auth.Abstractions/1.0.0-preview.1": { + "dependencies": { + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.Auth.Abstractions.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Auth.Client/1.0.0-preview.1": { + "dependencies": { + "Microsoft.Extensions.Http.Polly": "10.0.0-rc.2.25502.107", + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Abstractions": "1.0.0-preview.1", + "StellaOps.Configuration": "1.0.0" + }, + "runtime": { + "StellaOps.Auth.Client.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Auth.Security/1.0.0-preview.1": { + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0", + "StackExchange.Redis": "2.8.24", + "System.IdentityModel.Tokens.Jwt": "7.2.0" + }, + "runtime": { + "StellaOps.Auth.Security.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Authority.Plugins.Abstractions/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Abstractions": "1.0.0-preview.1", + "StellaOps.Cryptography": "1.0.0" + }, + "runtime": { + "StellaOps.Authority.Plugins.Abstractions.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Configuration/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Binder": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Configuration.Json": "10.0.0-rc.2.25502.107", + "NetEscapades.Configuration.Yaml": "2.1.0", + "SharpCompress": "0.41.0", + "StellaOps.Authority.Plugins.Abstractions": "1.0.0", + "StellaOps.Cryptography": "1.0.0" + }, + "runtime": { + "StellaOps.Configuration.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Cryptography/1.0.0": { + "dependencies": { + "Konscious.Security.Cryptography.Argon2": "1.3.1", + "Microsoft.IdentityModel.Tokens": "8.14.0", + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.Cryptography.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.DependencyInjection/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0" + }, + "runtime": { + "StellaOps.DependencyInjection.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Plugin/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.DependencyInjection": "1.0.0" + }, + "runtime": { + "StellaOps.Plugin.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Scanner.Analyzers.Lang/1.0.0": { + "dependencies": { + "SharpCompress": "0.41.0", + "StellaOps.Plugin": "1.0.0", + "StellaOps.Scanner.Core": "1.0.0" + }, + "runtime": { + "StellaOps.Scanner.Analyzers.Lang.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "StellaOps.Scanner.Core/1.0.0": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0-rc.2.25502.107", + "Microsoft.Extensions.Options": "10.0.0-rc.2.25502.107", + "SharpCompress": "0.41.0", + "StellaOps.Auth.Client": "1.0.0-preview.1", + "StellaOps.Auth.Security": "1.0.0-preview.1" + }, + "runtime": { + "StellaOps.Scanner.Core.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "StellaOps.Scanner.Analyzers.Lang.Rust/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Konscious.Security.Cryptography.Argon2/1.3.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-T+OAGwzYYXftahpOxO7J4xA5K6urxwGnWQf3M+Jpi+76Azv/0T3M5SuN+h7/QvXuiqNw3ZEZ5QqVLI5ygDAylw==", + "path": "konscious.security.cryptography.argon2/1.3.1", + "hashPath": "konscious.security.cryptography.argon2.1.3.1.nupkg.sha512" + }, + "Konscious.Security.Cryptography.Blake2/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-odwOyzj/J/lHJZNwFWJGU/LRecBShupAJ2S8TQqZfhUe9niHzu/voBYK5wuVKsvSpzbfupKQYZguVyIk1sgOkQ==", + "path": "konscious.security.cryptography.blake2/1.1.1", + "hashPath": "konscious.security.cryptography.blake2.1.1.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-273Ggibh3DdVrj47ENbUGIirOiqmLTAizpkvOD584Ps6NL/CMXPzesijnJgsjp7Fv/UCp69FKYBaSxZZ3q5R9g==", + "path": "microsoft.extensions.configuration/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-H+i/Qy30Rg/K9BcW2Z6DCHPCzwMH3bCwNOjEz31shWTUDK8GeeeMnrKVusprTcRA2Y6yPST+hg2zc3whPEs14Q==", + "path": "microsoft.extensions.configuration.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Binder/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-aA6/V6lw1Gueyb1PqhHAl/i/qUUuv+Fusfk4oaMOzzOjspBkYtPpNHCmml/0t1x0/DnZoed+u2WwpP+mSwd8Dg==", + "path": "microsoft.extensions.configuration.binder/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.binder.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-2SV60IUAWfluZv2YHNZ+nUOljYHGIsy96FpJs+N9/bgKDYs9qr6DdzPeIhiHrz+XvRzbybvcwtTBf5dKrYN4oA==", + "path": "microsoft.extensions.configuration.environmentvariables/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.environmentvariables.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.FileExtensions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5KrgXSTFR8cFLmDXXoT7GLVvDyHNw0Z9xG4doD78Q/HdlAR4jiMzmLLS9GFXrPGopmC6qqEZr2VBJHEu16INcA==", + "path": "microsoft.extensions.configuration.fileextensions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.fileextensions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Json/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-USwHuFz4BFKoaqSydHWH/d7Mr+fVsAh9S0S9pdsdHro1IixMbqQ9Gpo2sEZf25e3tZSq/ts6XsVmrQWmxmDhYA==", + "path": "microsoft.extensions.configuration.json/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.configuration.json.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-mDw80K98jBWCyLFCra51PRv+Ttnjse1lZIzXEFybKby0/ajBFTEeHj/4r/QJexmb8Uun0yaFH1HlFtmHP1YEVA==", + "path": "microsoft.extensions.dependencyinjection/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.dependencyinjection.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-8jujunpkNNfTkE9PFHp9/aD6GPKVfNCuz8tUbzOcyU5tQOCoIZId4hwQNVx3Tb8XEWw9BYdh0k5vPpqdCM+UtA==", + "path": "microsoft.extensions.dependencyinjection.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-tQfQFXI+ZQcL2RzDarDLx3Amh0WCp1KPGp1ie3y/CMV5hDhEq98WTmcMoXrFY0GkYLEaCQlVi2A6qVLcooG2Ow==", + "path": "microsoft.extensions.diagnostics/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.diagnostics.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-x6XVv3RiwOlN2unjyX/Zat0gI0HiRoDDdjkwBCwsMftYWpbJu4SiyRwDbrv2zAF8v8nbEEvcWi3/pUxZfaqLQw==", + "path": "microsoft.extensions.diagnostics.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.diagnostics.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dOpmW14MkOZIwV6269iXhoMp6alCHBoxqCR4pJ37GLjFaBIyzsIy+Ra8tsGmjHtFvEHKq0JRDIsb1PUkrK+yxw==", + "path": "microsoft.extensions.fileproviders.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.fileproviders.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Physical/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3+RiR6TEakDL0dCUqR7PjFffyrVMLdx/vAVBiN1mGmwScKYCTePIkYVkWsX85CTKh7R9J4M9C1MHzVdjbKcg3g==", + "path": "microsoft.extensions.fileproviders.physical/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.fileproviders.physical.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.FileSystemGlobbing/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XtcPOKB7sMFzj8SxaOglZV3eaqZ1GxUMVZTwaz4pRpBt0S45ghb836uUej4YaI8EzsnUJoqzOIKrTW4CDJMfVw==", + "path": "microsoft.extensions.filesystemglobbing/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.filesystemglobbing.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Http/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-d60bvi/NpzkpVlSpxZqOfdjX1hrQgL/byWVc3PryjbmB7zvfLtqQbYifjEWToqtS0Fb1rGnkuVI5JEdOnK1tNQ==", + "path": "microsoft.extensions.http/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.http.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Http.Polly/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-aY5vLcrhdXCHsCjYI2lNwfat2vdSuiPs0FFZiy7IM6zcyqdxaefG8J8ezTKkZyiuAtznjVJJT70B660l/WlsxA==", + "path": "microsoft.extensions.http.polly/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.http.polly.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Logging/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-q2C5gq86qkTmcYSJJSnw8sgTUyuqENYSOjk/NOYjHnYlKSrK3oI9Rjv1bWFpx2I3Btq9ZBEJb9aMM+IUQ0PvZA==", + "path": "microsoft.extensions.logging/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.logging.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Abstractions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SKKKZjyCpBaDQ7yuFjdk6ELnRBRWeZsbnzUfo59Wc4PGhgf92chE3we/QlT6nk6NqlWcUgH/jogM+B/uq/Qdnw==", + "path": "microsoft.extensions.logging.abstractions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.logging.abstractions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Options/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Ib6BCCjisp7ZUdhtNpSulFO0ODhz/IE4ZZd8OCqQWoRs363BQ0QOZi9KwpqpiEWo51S0kIXWqNicDPGXwpt9pQ==", + "path": "microsoft.extensions.options/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.options.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-MFbT8+JKX49YCXEFvlZDzQzI/R3QKzRZlb4dSud+569cMgA9hWbndjWWvOgGASoRcXynGRrBSq1Bw3PeCsB5/Q==", + "path": "microsoft.extensions.options.configurationextensions/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.options.configurationextensions.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.Extensions.Primitives/10.0.0-rc.2.25502.107": { + "type": "package", + "serviceable": true, + "sha512": "sha512-9pm2zqqn5u/OsKs2zgkhJEQQeMx9KkVOWPdHrs7Kt5sfpk+eIh/gmpi/mMH/ljS2T/PFsFdCEtm+GS/6l7zoZA==", + "path": "microsoft.extensions.primitives/10.0.0-rc.2.25502.107", + "hashPath": "microsoft.extensions.primitives.10.0.0-rc.2.25502.107.nupkg.sha512" + }, + "Microsoft.IdentityModel.Abstractions/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==", + "path": "microsoft.identitymodel.abstractions/8.14.0", + "hashPath": "microsoft.identitymodel.abstractions.8.14.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.JsonWebTokens/7.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zLFA9IBxDWw6Y1nz2PPZyQvF+ZZ4aW1pwgtwusQB39lgxOc2xVqZ8gitsuT1rwyuIbchGOWbax4fsJ8OgGRxSQ==", + "path": "microsoft.identitymodel.jsonwebtokens/7.2.0", + "hashPath": "microsoft.identitymodel.jsonwebtokens.7.2.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.Logging/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==", + "path": "microsoft.identitymodel.logging/8.14.0", + "hashPath": "microsoft.identitymodel.logging.8.14.0.nupkg.sha512" + }, + "Microsoft.IdentityModel.Tokens/8.14.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ySPkj429HrYHvwLVNoPZdQ/bKZZKSkuWKod68qxo+5/pLdXFimgflckKgAZclX9tuO9qWk/KFiIN65diMWgh+g==", + "path": "microsoft.identitymodel.tokens/8.14.0", + "hashPath": "microsoft.identitymodel.tokens.8.14.0.nupkg.sha512" + }, + "NetEscapades.Configuration.Yaml/2.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kNTX7kvRvbzBpLd3Vg9iu6t60tTyhVxsruAPgH6kl1GkAZIHLZw9cQysvjUenDU7JEnUgyxQnzfL8627ARDn+g==", + "path": "netescapades.configuration.yaml/2.1.0", + "hashPath": "netescapades.configuration.yaml.2.1.0.nupkg.sha512" + }, + "Pipelines.Sockets.Unofficial/2.2.8": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "path": "pipelines.sockets.unofficial/2.2.8", + "hashPath": "pipelines.sockets.unofficial.2.2.8.nupkg.sha512" + }, + "Polly/7.2.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bw00Ck5sh6ekduDE3mnCo1ohzuad946uslCDEENu3091+6UKnBuKLo4e+yaNcCzXxOZCXWY2gV4a35+K1d4LDA==", + "path": "polly/7.2.4", + "hashPath": "polly.7.2.4.nupkg.sha512" + }, + "Polly.Extensions.Http/3.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "path": "polly.extensions.http/3.0.0", + "hashPath": "polly.extensions.http.3.0.0.nupkg.sha512" + }, + "SharpCompress/0.41.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-z04dBVdTIAFTRKi38f0LkajaKA++bR+M8kYCbasXePILD2H+qs7CkLpyiippB24CSbTrWIgpBKm6BenZqkUwvw==", + "path": "sharpcompress/0.41.0", + "hashPath": "sharpcompress.0.41.0.nupkg.sha512" + }, + "StackExchange.Redis/2.8.24": { + "type": "package", + "serviceable": true, + "sha512": "sha512-GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", + "path": "stackexchange.redis/2.8.24", + "hashPath": "stackexchange.redis.2.8.24.nupkg.sha512" + }, + "System.IdentityModel.Tokens.Jwt/7.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Z3Fmkrxkp+o51ANMO/PqASRRlEz8dH4mTWwZXMFMXZt2bUGztBiNcIDnwBCElYLYpzpmz4sIqHb6aW8QVLe6YQ==", + "path": "system.identitymodel.tokens.jwt/7.2.0", + "hashPath": "system.identitymodel.tokens.jwt.7.2.0.nupkg.sha512" + }, + "YamlDotNet/9.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-fuvGXU4Ec5HrsmEc+BiFTNPCRf1cGBI2kh/3RzMWgddM2M4ALhbSPoI3X3mhXZUD1qqQd9oSkFAtWjpz8z9eRg==", + "path": "yamldotnet/9.1.0", + "hashPath": "yamldotnet.9.1.0.nupkg.sha512" + }, + "ZstdSharp.Port/0.8.6": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iP4jVLQoQmUjMU88g1WObiNr6YKZGvh4aOXn3yOJsHqZsflwRsxZPcIBvNXgjXO3vQKSLctXGLTpcBPLnWPS8A==", + "path": "zstdsharp.port/0.8.6", + "hashPath": "zstdsharp.port.0.8.6.nupkg.sha512" + }, + "StellaOps.Auth.Abstractions/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Auth.Client/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Auth.Security/1.0.0-preview.1": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Authority.Plugins.Abstractions/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Configuration/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Cryptography/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.DependencyInjection/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Plugin/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Scanner.Analyzers.Lang/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "StellaOps.Scanner.Core/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } } \ No newline at end of file diff --git a/samples/api/reports/report-sample.dsse.json b/samples/api/reports/report-sample.dsse.json index 28c8bb9e..a097361e 100644 --- a/samples/api/reports/report-sample.dsse.json +++ b/samples/api/reports/report-sample.dsse.json @@ -1,11 +1,80 @@ { - "payloadType": "application/vnd.stellaops.report+json", - "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXX0=", - "signatures": [ - { - "keyId": "test-key", - "algorithm": "hs256", - "signature": "signature-value" + "report": { + "reportId": "report-abc", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "verdict": "blocked", + "policy": { + "revisionId": "rev-42", + "digest": "digest-123" + }, + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 + }, + "verdicts": [ + { + "findingId": "finding-1", + "reachability": "runtime", + "score": 47.5, + "sourceTrust": "NVD", + "status": "Blocked" + } + ], + "issues": [], + "surface": { + "tenant": "tenant-alpha", + "generatedAt": "2025-10-19T12:34:56+00:00", + "manifestDigest": "sha256:4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/tenant-alpha/sha256/4f/ee/4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "tenant-alpha", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "artifacts": [ + { + "kind": "entry-trace", + "uri": "cas://scanner-artifacts/scanner/entry-trace/f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0/entry-trace.json", + "digest": "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + "mediaType": "application/json", + "format": "json", + "sizeBytes": 4096 + }, + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom.cdx.json", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=inventory", + "format": "cdx-json", + "sizeBytes": 24576, + "view": "inventory" + }, + { + "kind": "sbom-usage", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom-usage.cdx.json", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=usage", + "format": "cdx-json", + "sizeBytes": 16384, + "view": "usage" + } + ] + } } - ] + }, + "dsse": { + "payloadType": "application/vnd.stellaops.report+json", + "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJyZWFjaGFiaWxpdHkiOiJydW50aW1lIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwic3RhdHVzIjoiQmxvY2tlZCJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHgranNvbjt2ZXJzaW9uPTEuNjt2aWV3PWludmVudG9yeSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoyNDU3NiwidmlldyI6ImludmVudG9yeSJ9LHsia2luZCI6InNib20tdXNhZ2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2ltYWdlcy9mZWVkZmFjZS9zYm9tLXVzYWdlLmNkeC5qc29uIiwiZGlnZXN0Ijoic2hhMjU2OjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIiLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuY3ljbG9uZWR4K2pzb247dmVyc2lvbj0xLjY7dmlldz11c2FnZSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoxNjM4NCwidmlldyI6InVzYWdlIn1dfX19", + "signatures": [ + { + "keyId": "test-key", + "algorithm": "hs256", + "signature": "signature-value" + } + ] + } } diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiMetrics.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiMetrics.cs index e7de1824..99935eda 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiMetrics.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiMetrics.cs @@ -1,28 +1,28 @@ -using System.Diagnostics.Metrics; - -namespace StellaOps.AdvisoryAI.Hosting; - -public sealed class AdvisoryAiMetrics -{ - private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0"); - - private readonly Counter _requests; - private readonly Counter _queuePublished; - private readonly Counter _queueProcessed; - - public AdvisoryAiMetrics() - { - _requests = Meter.CreateCounter("advisory_ai_pipeline_requests_total"); - _queuePublished = Meter.CreateCounter("advisory_ai_pipeline_messages_enqueued_total"); - _queueProcessed = Meter.CreateCounter("advisory_ai_pipeline_messages_processed_total"); - } - - public void RecordRequest(string taskType) - => _requests.Add(1, KeyValuePair.Create("task_type", taskType)); - - public void RecordEnqueued(string taskType) - => _queuePublished.Add(1, KeyValuePair.Create("task_type", taskType)); - - public void RecordProcessed(string taskType) - => _queueProcessed.Add(1, KeyValuePair.Create("task_type", taskType)); -} +using System.Diagnostics.Metrics; + +namespace StellaOps.AdvisoryAI.Hosting; + +public sealed class AdvisoryAiMetrics +{ + private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0"); + + private readonly Counter _requests; + private readonly Counter _queuePublished; + private readonly Counter _queueProcessed; + + public AdvisoryAiMetrics() + { + _requests = Meter.CreateCounter("advisory_ai_pipeline_requests_total"); + _queuePublished = Meter.CreateCounter("advisory_ai_pipeline_messages_enqueued_total"); + _queueProcessed = Meter.CreateCounter("advisory_ai_pipeline_messages_processed_total"); + } + + public void RecordRequest(string taskType) + => _requests.Add(1, KeyValuePair.Create("task_type", taskType)); + + public void RecordEnqueued(string taskType) + => _queuePublished.Add(1, KeyValuePair.Create("task_type", taskType)); + + public void RecordProcessed(string taskType) + => _queueProcessed.Add(1, KeyValuePair.Create("task_type", taskType)); +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs index 9eba0a1b..ec1186b4 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/ServiceCollectionExtensions.cs @@ -1,33 +1,33 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using StellaOps.AdvisoryAI.DependencyInjection; using StellaOps.AdvisoryAI.Providers; using StellaOps.AdvisoryAI.Queue; - -namespace StellaOps.AdvisoryAI.Hosting; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddAdvisoryAiCore( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - - services.AddOptions() - .Bind(configuration.GetSection("AdvisoryAI")) + +namespace StellaOps.AdvisoryAI.Hosting; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAdvisoryAiCore( + this IServiceCollection services, + IConfiguration configuration, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection("AdvisoryAI")) .PostConfigure(options => { configure?.Invoke(options); AdvisoryAiServiceOptionsValidator.Validate(options); }) .ValidateOnStart(); - + services.AddOptions() .Configure>((target, source) => { diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/StellaOps.AdvisoryAI.Hosting.csproj b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/StellaOps.AdvisoryAI.Hosting.csproj index 35480b38..e785bcb5 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/StellaOps.AdvisoryAI.Hosting.csproj +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/StellaOps.AdvisoryAI.Hosting.csproj @@ -1,12 +1,12 @@ - - - net10.0 - preview - enable - enable - true - - - - - + + + net10.0 + preview + enable + enable + true + + + + + diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj index 5cba51c2..d262d2d6 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj @@ -1,13 +1,13 @@ - - - net10.0 - preview - enable - enable - true - - - - - - + + + net10.0 + preview + enable + enable + true + + + + + + diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.Development.json b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.Development.json index a6e86ace..bc7bab38 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.Development.json +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.Development.json @@ -1,8 +1,8 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Warning" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.json b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.json index e4cf174b..cd91e4f3 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.json +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/appsettings.json @@ -1,14 +1,14 @@ -{ - "AdvisoryAI": { - "SbomBaseAddress": "http://localhost:5210/", - "Queue": { - "DirectoryPath": "../var/advisory-ai-queue" - } - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} +{ + "AdvisoryAI": { + "SbomBaseAddress": "http://localhost:5210/", + "Queue": { + "DirectoryPath": "../var/advisory-ai-queue" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj index ebf6b05c..063aa1e0 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj @@ -1,13 +1,13 @@ - - - net10.0 - preview - enable - enable - true - - - - - - + + + net10.0 + preview + enable + enable + true + + + + + + diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.Development.json b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.Development.json index dc23838b..827a2f87 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.Development.json +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.Development.json @@ -1,7 +1,7 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json index ecd95a51..015a90eb 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json @@ -1,13 +1,13 @@ -{ - "AdvisoryAI": { - "SbomBaseAddress": "http://localhost:5210/", - "Queue": { - "DirectoryPath": "../var/advisory-ai-queue" - } - }, - "Logging": { - "LogLevel": { - "Default": "Information" - } - } -} +{ + "AdvisoryAI": { + "SbomBaseAddress": "http://localhost:5210/", + "Queue": { + "DirectoryPath": "../var/advisory-ai-queue" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.sln b/src/AdvisoryAI/StellaOps.AdvisoryAI.sln index 2eb07282..a651b4f6 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.sln +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.sln @@ -1,235 +1,235 @@ - -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.AdvisoryAI", "StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj", "{E41E2FDA-3827-4B18-8596-B25BDE882D5F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Tests", "__Tests\StellaOps.AdvisoryAI.Tests\StellaOps.AdvisoryAI.Tests.csproj", "{F6860DE5-0C7C-4848-8356-7555E3C391A3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\Concelier\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{B53E4FED-8988-4354-8D1A-D3C618DBFD78}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E98A7C01-1619-41A0-A586-84EF9952F75D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\Concelier\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{BBB5CD3C-866A-4298-ACE1-598413631CF5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{1313202A-E8A8-41E3-80BC-472096074681}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F567F20C-552F-4761-941A-0552CEF68160}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{C8CE71D3-952A-43F7-9346-20113E37F672}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Hosting", "StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj", "{F3E0EA9E-E4F0-428A-804B-A599870B971D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.WebService", "StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj", "{AD5CEACE-7BF5-4D48-B473-D60188844A0A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Worker", "StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj", "{BC68381E-B6EF-4481-8487-00267624D18C}" -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 - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.ActiveCfg = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.Build.0 = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.ActiveCfg = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.Build.0 = Debug|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.Build.0 = Release|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.ActiveCfg = Release|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.Build.0 = Release|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.ActiveCfg = Release|Any CPU - {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.Build.0 = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.ActiveCfg = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.Build.0 = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.ActiveCfg = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.Build.0 = Debug|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.Build.0 = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.ActiveCfg = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.Build.0 = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.ActiveCfg = Release|Any CPU - {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.Build.0 = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.ActiveCfg = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.Build.0 = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.ActiveCfg = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.Build.0 = Debug|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.Build.0 = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.ActiveCfg = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.Build.0 = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.ActiveCfg = Release|Any CPU - {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.Build.0 = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.ActiveCfg = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.Build.0 = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.ActiveCfg = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.Build.0 = Debug|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.Build.0 = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.ActiveCfg = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.Build.0 = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.ActiveCfg = Release|Any CPU - {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.Build.0 = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.Build.0 = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.ActiveCfg = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.Build.0 = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.ActiveCfg = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.Build.0 = Debug|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.ActiveCfg = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.Build.0 = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.ActiveCfg = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.Build.0 = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.ActiveCfg = Release|Any CPU - {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.Build.0 = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.ActiveCfg = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.Build.0 = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.ActiveCfg = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.Build.0 = Debug|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.Build.0 = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.ActiveCfg = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.Build.0 = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.ActiveCfg = Release|Any CPU - {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.Build.0 = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.ActiveCfg = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.Build.0 = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.ActiveCfg = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.Build.0 = Debug|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.Build.0 = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.ActiveCfg = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.Build.0 = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.ActiveCfg = Release|Any CPU - {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.Build.0 = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.ActiveCfg = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.Build.0 = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.ActiveCfg = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.Build.0 = Debug|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.Build.0 = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.ActiveCfg = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.Build.0 = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.ActiveCfg = Release|Any CPU - {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.Build.0 = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.ActiveCfg = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.Build.0 = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.ActiveCfg = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.Build.0 = Debug|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.Build.0 = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.ActiveCfg = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.Build.0 = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.ActiveCfg = Release|Any CPU - {1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.Build.0 = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.ActiveCfg = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.Build.0 = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.ActiveCfg = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.Build.0 = Debug|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.Build.0 = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.ActiveCfg = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.Build.0 = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.ActiveCfg = Release|Any CPU - {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.Build.0 = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.ActiveCfg = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.Build.0 = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.ActiveCfg = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.Build.0 = Debug|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.Build.0 = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.ActiveCfg = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.Build.0 = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.ActiveCfg = Release|Any CPU - {F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.Build.0 = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.ActiveCfg = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.Build.0 = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.ActiveCfg = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.Build.0 = Debug|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.Build.0 = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.ActiveCfg = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.Build.0 = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.ActiveCfg = Release|Any CPU - {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.Build.0 = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.ActiveCfg = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.Build.0 = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.ActiveCfg = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.Build.0 = Debug|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.Build.0 = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.ActiveCfg = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.Build.0 = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.ActiveCfg = Release|Any CPU - {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.Build.0 = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.ActiveCfg = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.Build.0 = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.ActiveCfg = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.Build.0 = Debug|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.Build.0 = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.ActiveCfg = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.Build.0 = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.ActiveCfg = Release|Any CPU - {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.Build.0 = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.ActiveCfg = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.Build.0 = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.ActiveCfg = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.Build.0 = Debug|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.Build.0 = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.ActiveCfg = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.Build.0 = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.ActiveCfg = Release|Any CPU - {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {F6860DE5-0C7C-4848-8356-7555E3C391A3} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642} - EndGlobalSection -EndGlobal + +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.AdvisoryAI", "StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj", "{E41E2FDA-3827-4B18-8596-B25BDE882D5F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Tests", "__Tests\StellaOps.AdvisoryAI.Tests\StellaOps.AdvisoryAI.Tests.csproj", "{F6860DE5-0C7C-4848-8356-7555E3C391A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\Concelier\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{B53E4FED-8988-4354-8D1A-D3C618DBFD78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E98A7C01-1619-41A0-A586-84EF9952F75D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\Concelier\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{BBB5CD3C-866A-4298-ACE1-598413631CF5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{1313202A-E8A8-41E3-80BC-472096074681}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F567F20C-552F-4761-941A-0552CEF68160}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{C8CE71D3-952A-43F7-9346-20113E37F672}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Hosting", "StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj", "{F3E0EA9E-E4F0-428A-804B-A599870B971D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.WebService", "StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj", "{AD5CEACE-7BF5-4D48-B473-D60188844A0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Worker", "StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj", "{BC68381E-B6EF-4481-8487-00267624D18C}" +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 + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.Build.0 = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.Build.0 = Debug|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.Build.0 = Release|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.ActiveCfg = Release|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.Build.0 = Release|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.ActiveCfg = Release|Any CPU + {E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.Build.0 = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.Build.0 = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.Build.0 = Debug|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.Build.0 = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.ActiveCfg = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.Build.0 = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.ActiveCfg = Release|Any CPU + {F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.Build.0 = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.ActiveCfg = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.Build.0 = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.ActiveCfg = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.Build.0 = Debug|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.Build.0 = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.ActiveCfg = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.Build.0 = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.ActiveCfg = Release|Any CPU + {B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.Build.0 = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.ActiveCfg = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.Build.0 = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.ActiveCfg = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.Build.0 = Debug|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.Build.0 = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.ActiveCfg = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.Build.0 = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.ActiveCfg = Release|Any CPU + {E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.Build.0 = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.ActiveCfg = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.Build.0 = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.ActiveCfg = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.Build.0 = Debug|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.Build.0 = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.ActiveCfg = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.Build.0 = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.ActiveCfg = Release|Any CPU + {973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.Build.0 = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.ActiveCfg = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.Build.0 = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.Build.0 = Debug|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.Build.0 = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.ActiveCfg = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.Build.0 = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.ActiveCfg = Release|Any CPU + {F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.Build.0 = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.ActiveCfg = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.Build.0 = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.ActiveCfg = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.Build.0 = Debug|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.Build.0 = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.ActiveCfg = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.Build.0 = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.ActiveCfg = Release|Any CPU + {BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.Build.0 = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.Build.0 = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.Build.0 = Debug|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.Build.0 = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.ActiveCfg = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.Build.0 = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.ActiveCfg = Release|Any CPU + {7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.Build.0 = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.ActiveCfg = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.Build.0 = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.ActiveCfg = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.Build.0 = Debug|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.Build.0 = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.ActiveCfg = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.Build.0 = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.ActiveCfg = Release|Any CPU + {1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.Build.0 = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.ActiveCfg = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.Build.0 = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.Build.0 = Debug|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.Build.0 = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.ActiveCfg = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.Build.0 = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.ActiveCfg = Release|Any CPU + {1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.Build.0 = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.ActiveCfg = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.Build.0 = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.ActiveCfg = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.Build.0 = Debug|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.Build.0 = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.ActiveCfg = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.Build.0 = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.ActiveCfg = Release|Any CPU + {F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.Build.0 = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.Build.0 = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.Build.0 = Debug|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.Build.0 = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.ActiveCfg = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.Build.0 = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.ActiveCfg = Release|Any CPU + {C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.Build.0 = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.Build.0 = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.Build.0 = Debug|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.Build.0 = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.ActiveCfg = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.Build.0 = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.ActiveCfg = Release|Any CPU + {F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.Build.0 = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.Build.0 = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.Build.0 = Debug|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.Build.0 = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.ActiveCfg = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.Build.0 = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.ActiveCfg = Release|Any CPU + {AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.Build.0 = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.Build.0 = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.Build.0 = Debug|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.Build.0 = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.ActiveCfg = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.Build.0 = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.ActiveCfg = Release|Any CPU + {BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F6860DE5-0C7C-4848-8356-7555E3C391A3} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642} + EndGlobalSection +EndGlobal diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/DependencyInjection/SbomContextServiceCollectionExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/DependencyInjection/SbomContextServiceCollectionExtensions.cs index 41effdfe..c78638f0 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/DependencyInjection/SbomContextServiceCollectionExtensions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/DependencyInjection/SbomContextServiceCollectionExtensions.cs @@ -1,41 +1,41 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; -using StellaOps.AdvisoryAI.Abstractions; -using StellaOps.AdvisoryAI.Providers; -using StellaOps.AdvisoryAI.Retrievers; - -namespace StellaOps.AdvisoryAI.DependencyInjection; - -public static class SbomContextServiceCollectionExtensions -{ - public static IServiceCollection AddSbomContext(this IServiceCollection services, Action? configure = null) - { - ArgumentNullException.ThrowIfNull(services); - - var optionsBuilder = services.AddOptions(); - if (configure is not null) - { - optionsBuilder.Configure(configure); - } - - services.AddHttpClient((serviceProvider, client) => - { - var options = serviceProvider.GetRequiredService>().Value; - if (options.BaseAddress is not null) - { - client.BaseAddress = options.BaseAddress; - } - - if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName)) - { - client.DefaultRequestHeaders.Remove(options.TenantHeaderName); - client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant); - } - }); - - services.TryAddSingleton(); - return services; - } -} +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using StellaOps.AdvisoryAI.Abstractions; +using StellaOps.AdvisoryAI.Providers; +using StellaOps.AdvisoryAI.Retrievers; + +namespace StellaOps.AdvisoryAI.DependencyInjection; + +public static class SbomContextServiceCollectionExtensions +{ + public static IServiceCollection AddSbomContext(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsBuilder = services.AddOptions(); + if (configure is not null) + { + optionsBuilder.Configure(configure); + } + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + if (options.BaseAddress is not null) + { + client.BaseAddress = options.BaseAddress; + } + + if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName)) + { + client.DefaultRequestHeaders.Remove(options.TenantHeaderName); + client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant); + } + }); + + services.TryAddSingleton(); + return services; + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryPipelineOrchestrator.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryPipelineOrchestrator.cs index 2ec88490..88f385ec 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryPipelineOrchestrator.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryPipelineOrchestrator.cs @@ -4,118 +4,118 @@ using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.AdvisoryAI.Abstractions; -using StellaOps.AdvisoryAI.Context; -using StellaOps.AdvisoryAI.Tools; - -namespace StellaOps.AdvisoryAI.Orchestration; - -internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator -{ - private readonly IAdvisoryStructuredRetriever _structuredRetriever; - private readonly IAdvisoryVectorRetriever _vectorRetriever; - private readonly ISbomContextRetriever _sbomContextRetriever; - private readonly IDeterministicToolset _toolset; - private readonly AdvisoryPipelineOptions _options; - private readonly ILogger? _logger; - - public AdvisoryPipelineOrchestrator( - IAdvisoryStructuredRetriever structuredRetriever, - IAdvisoryVectorRetriever vectorRetriever, - ISbomContextRetriever sbomContextRetriever, - IDeterministicToolset toolset, - IOptions options, - ILogger? logger = null) - { - _structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever)); - _vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever)); - _sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever)); - _toolset = toolset ?? throw new ArgumentNullException(nameof(toolset)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _options.ApplyDefaults(); - _logger = logger; - } - - public async Task CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var config = _options.GetConfiguration(request.TaskType); - - var structuredRequest = new AdvisoryRetrievalRequest( - request.AdvisoryKey, - request.PreferredSections, - config.StructuredMaxChunks); - - var structured = await _structuredRetriever - .RetrieveAsync(structuredRequest, cancellationToken) - .ConfigureAwait(false); - - var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false); - var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false); - - var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis); - var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis); - - var plan = new AdvisoryTaskPlan( - request, - cacheKey, - config.PromptTemplate, - structured.Chunks.ToImmutableArray(), - vectorResults, - sbomContext, - dependencyAnalysis, - config.Budget, - metadata); - - return plan; - } - - private async Task> RetrieveVectorMatchesAsync( - AdvisoryTaskRequest request, - AdvisoryRetrievalRequest structuredRequest, - AdvisoryTaskConfiguration configuration, - CancellationToken cancellationToken) - { - if (configuration.VectorQueries.Count == 0) - { - return ImmutableArray.Empty; - } - - var builder = ImmutableArray.CreateBuilder(configuration.VectorQueries.Count); - foreach (var query in configuration.GetVectorQueries()) - { - var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK); - var matches = await _vectorRetriever - .SearchAsync(vectorRequest, cancellationToken) - .ConfigureAwait(false); - - builder.Add(new AdvisoryVectorResult(query, matches.ToImmutableArray())); - } - - return builder.MoveToImmutable(); - } - - private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync( - AdvisoryTaskRequest request, - AdvisoryTaskConfiguration configuration, - CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(request.ArtifactId)) - { - return (null, null); - } - - var sbomRequest = new SbomContextRequest( - artifactId: request.ArtifactId!, - purl: request.ArtifactPurl, - maxTimelineEntries: configuration.SbomMaxTimelineEntries, - maxDependencyPaths: configuration.SbomMaxDependencyPaths, - includeEnvironmentFlags: configuration.IncludeEnvironmentFlags, - includeBlastRadius: configuration.IncludeBlastRadius); - +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.AdvisoryAI.Abstractions; +using StellaOps.AdvisoryAI.Context; +using StellaOps.AdvisoryAI.Tools; + +namespace StellaOps.AdvisoryAI.Orchestration; + +internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator +{ + private readonly IAdvisoryStructuredRetriever _structuredRetriever; + private readonly IAdvisoryVectorRetriever _vectorRetriever; + private readonly ISbomContextRetriever _sbomContextRetriever; + private readonly IDeterministicToolset _toolset; + private readonly AdvisoryPipelineOptions _options; + private readonly ILogger? _logger; + + public AdvisoryPipelineOrchestrator( + IAdvisoryStructuredRetriever structuredRetriever, + IAdvisoryVectorRetriever vectorRetriever, + ISbomContextRetriever sbomContextRetriever, + IDeterministicToolset toolset, + IOptions options, + ILogger? logger = null) + { + _structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever)); + _vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever)); + _sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever)); + _toolset = toolset ?? throw new ArgumentNullException(nameof(toolset)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _options.ApplyDefaults(); + _logger = logger; + } + + public async Task CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var config = _options.GetConfiguration(request.TaskType); + + var structuredRequest = new AdvisoryRetrievalRequest( + request.AdvisoryKey, + request.PreferredSections, + config.StructuredMaxChunks); + + var structured = await _structuredRetriever + .RetrieveAsync(structuredRequest, cancellationToken) + .ConfigureAwait(false); + + var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false); + var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false); + + var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis); + var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis); + + var plan = new AdvisoryTaskPlan( + request, + cacheKey, + config.PromptTemplate, + structured.Chunks.ToImmutableArray(), + vectorResults, + sbomContext, + dependencyAnalysis, + config.Budget, + metadata); + + return plan; + } + + private async Task> RetrieveVectorMatchesAsync( + AdvisoryTaskRequest request, + AdvisoryRetrievalRequest structuredRequest, + AdvisoryTaskConfiguration configuration, + CancellationToken cancellationToken) + { + if (configuration.VectorQueries.Count == 0) + { + return ImmutableArray.Empty; + } + + var builder = ImmutableArray.CreateBuilder(configuration.VectorQueries.Count); + foreach (var query in configuration.GetVectorQueries()) + { + var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK); + var matches = await _vectorRetriever + .SearchAsync(vectorRequest, cancellationToken) + .ConfigureAwait(false); + + builder.Add(new AdvisoryVectorResult(query, matches.ToImmutableArray())); + } + + return builder.MoveToImmutable(); + } + + private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync( + AdvisoryTaskRequest request, + AdvisoryTaskConfiguration configuration, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.ArtifactId)) + { + return (null, null); + } + + var sbomRequest = new SbomContextRequest( + artifactId: request.ArtifactId!, + purl: request.ArtifactPurl, + maxTimelineEntries: configuration.SbomMaxTimelineEntries, + maxDependencyPaths: configuration.SbomMaxDependencyPaths, + includeEnvironmentFlags: configuration.IncludeEnvironmentFlags, + includeBlastRadius: configuration.IncludeBlastRadius); + var context = await _sbomContextRetriever .RetrieveAsync(sbomRequest, cancellationToken) .ConfigureAwait(false); @@ -128,73 +128,73 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat private static ImmutableDictionary BuildMetadata( AdvisoryTaskRequest request, AdvisoryRetrievalResult structured, - ImmutableArray vectors, - SbomContextResult? sbom, - DependencyAnalysisResult? dependency) - { - var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - builder["task_type"] = request.TaskType.ToString(); - builder["advisory_key"] = request.AdvisoryKey; - builder["profile"] = request.Profile; + ImmutableArray vectors, + SbomContextResult? sbom, + DependencyAnalysisResult? dependency) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + builder["task_type"] = request.TaskType.ToString(); + builder["advisory_key"] = request.AdvisoryKey; + builder["profile"] = request.Profile; builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture); - builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture); - builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture); - builder["includes_sbom"] = (sbom is not null).ToString(); - builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture); - builder["force_refresh"] = request.ForceRefresh.ToString(); - - if (!string.IsNullOrEmpty(request.PolicyVersion)) - { - builder["policy_version"] = request.PolicyVersion!; - } - - if (sbom is not null) - { + builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture); + builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture); + builder["includes_sbom"] = (sbom is not null).ToString(); + builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture); + builder["force_refresh"] = request.ForceRefresh.ToString(); + + if (!string.IsNullOrEmpty(request.PolicyVersion)) + { + builder["policy_version"] = request.PolicyVersion!; + } + + if (sbom is not null) + { builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture); builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture); - - if (!sbom.EnvironmentFlags.IsEmpty) - { - foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder[$"sbom_env_{flag.Key}"] = flag.Value; - } - } - - if (sbom.BlastRadius is not null) - { - builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture); - builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture); - builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture); - if (sbom.BlastRadius.ImpactedPercentage is not null) - { - builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture); - } - - if (!sbom.BlastRadius.Metadata.IsEmpty) - { - foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value; - } - } - } - - if (!sbom.Metadata.IsEmpty) - { - foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder[$"sbom_meta_{kvp.Key}"] = kvp.Value; - } - } - } - - if (dependency is not null) - { - foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder[$"dependency_{kvp.Key}"] = kvp.Value; - } + + if (!sbom.EnvironmentFlags.IsEmpty) + { + foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder[$"sbom_env_{flag.Key}"] = flag.Value; + } + } + + if (sbom.BlastRadius is not null) + { + builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture); + builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture); + builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture); + if (sbom.BlastRadius.ImpactedPercentage is not null) + { + builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture); + } + + if (!sbom.BlastRadius.Metadata.IsEmpty) + { + foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value; + } + } + } + + if (!sbom.Metadata.IsEmpty) + { + foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder[$"sbom_meta_{kvp.Key}"] = kvp.Value; + } + } + } + + if (dependency is not null) + { + foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder[$"dependency_{kvp.Key}"] = kvp.Value; + } } return builder.ToImmutable(); @@ -228,178 +228,178 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat context.Metadata); } - private static string ComputeCacheKey( - AdvisoryTaskRequest request, - AdvisoryRetrievalResult structured, - ImmutableArray vectors, - SbomContextResult? sbom, - DependencyAnalysisResult? dependency) - { - var builder = new StringBuilder(); - builder.Append(request.TaskType) - .Append('|').Append(request.AdvisoryKey) - .Append('|').Append(request.ArtifactId ?? string.Empty) - .Append('|').Append(request.PolicyVersion ?? string.Empty) - .Append('|').Append(request.Profile); - - if (request.PreferredSections is not null) - { - foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) - { - builder.Append('|').Append(section); - } - } - - foreach (var chunkId in structured.Chunks - .Select(chunk => chunk.ChunkId) - .OrderBy(id => id, StringComparer.Ordinal)) - { - builder.Append("|chunk:").Append(chunkId); - } - - foreach (var vector in vectors) - { - builder.Append("|query:").Append(vector.Query); - foreach (var match in vector.Matches - .OrderBy(m => m.ChunkId, StringComparer.Ordinal) - .ThenBy(m => m.Score)) - { - builder.Append("|match:") - .Append(match.ChunkId) - .Append('@') - .Append(match.Score.ToString("G", CultureInfo.InvariantCulture)); - } - } - - if (sbom is not null) - { + private static string ComputeCacheKey( + AdvisoryTaskRequest request, + AdvisoryRetrievalResult structured, + ImmutableArray vectors, + SbomContextResult? sbom, + DependencyAnalysisResult? dependency) + { + var builder = new StringBuilder(); + builder.Append(request.TaskType) + .Append('|').Append(request.AdvisoryKey) + .Append('|').Append(request.ArtifactId ?? string.Empty) + .Append('|').Append(request.PolicyVersion ?? string.Empty) + .Append('|').Append(request.Profile); + + if (request.PreferredSections is not null) + { + foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + { + builder.Append('|').Append(section); + } + } + + foreach (var chunkId in structured.Chunks + .Select(chunk => chunk.ChunkId) + .OrderBy(id => id, StringComparer.Ordinal)) + { + builder.Append("|chunk:").Append(chunkId); + } + + foreach (var vector in vectors) + { + builder.Append("|query:").Append(vector.Query); + foreach (var match in vector.Matches + .OrderBy(m => m.ChunkId, StringComparer.Ordinal) + .ThenBy(m => m.Score)) + { + builder.Append("|match:") + .Append(match.ChunkId) + .Append('@') + .Append(match.Score.ToString("G", CultureInfo.InvariantCulture)); + } + } + + if (sbom is not null) + { builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length); builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length); - foreach (var entry in sbom.VersionTimeline - .OrderBy(e => e.Version, StringComparer.Ordinal) - .ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds()) - .ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue) - .ThenBy(e => e.Status, StringComparer.Ordinal) - .ThenBy(e => e.Source, StringComparer.Ordinal)) - { - builder.Append("|timeline:") - .Append(entry.Version) - .Append('@') - .Append(entry.FirstObserved.ToUnixTimeMilliseconds()) - .Append('@') - .Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1) - .Append('@') - .Append(entry.Status) - .Append('@') - .Append(entry.Source); - } - - foreach (var path in sbom.DependencyPaths - .OrderBy(path => path.IsRuntime) - .ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal)) - { - builder.Append("|path:") - .Append(path.IsRuntime ? 'R' : 'D'); - - foreach (var node in path.Nodes) - { - builder.Append(":") - .Append(node.Identifier) - .Append('@') - .Append(node.Version ?? string.Empty); - } - - if (!string.IsNullOrWhiteSpace(path.Source)) - { - builder.Append("|pathsrc:").Append(path.Source); - } - - if (!path.Metadata.IsEmpty) - { - foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder.Append("|pathmeta:") - .Append(kvp.Key) - .Append('=') - .Append(kvp.Value); - } - } - } - - if (!sbom.EnvironmentFlags.IsEmpty) - { - foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder.Append("|env:") - .Append(flag.Key) - .Append('=') - .Append(flag.Value); - } - } - - if (sbom.BlastRadius is not null) - { - builder.Append("|blast:") - .Append(sbom.BlastRadius.ImpactedAssets) - .Append(',') - .Append(sbom.BlastRadius.ImpactedWorkloads) - .Append(',') - .Append(sbom.BlastRadius.ImpactedNamespaces) - .Append(',') - .Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty); - - if (!sbom.BlastRadius.Metadata.IsEmpty) - { - foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder.Append("|blastmeta:") - .Append(kvp.Key) - .Append('=') - .Append(kvp.Value); - } - } - } - - if (!sbom.Metadata.IsEmpty) - { - foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder.Append("|sbommeta:") - .Append(kvp.Key) - .Append('=') - .Append(kvp.Value); - } - } - } - - if (dependency is not null) - { - foreach (var node in dependency.Nodes - .OrderBy(n => n.Identifier, StringComparer.Ordinal)) - { - builder.Append("|dep:") - .Append(node.Identifier) - .Append(':') - .Append(node.RuntimeOccurrences) - .Append(':') - .Append(node.DevelopmentOccurrences) - .Append(':') - .Append(string.Join(',', node.Versions)); - } - - if (!dependency.Metadata.IsEmpty) - { - foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - { - builder.Append("|depmeta:") - .Append(kvp.Key) - .Append('=') - .Append(kvp.Value); - } - } - } - - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); - return Convert.ToHexString(hash); - } -} + foreach (var entry in sbom.VersionTimeline + .OrderBy(e => e.Version, StringComparer.Ordinal) + .ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds()) + .ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue) + .ThenBy(e => e.Status, StringComparer.Ordinal) + .ThenBy(e => e.Source, StringComparer.Ordinal)) + { + builder.Append("|timeline:") + .Append(entry.Version) + .Append('@') + .Append(entry.FirstObserved.ToUnixTimeMilliseconds()) + .Append('@') + .Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1) + .Append('@') + .Append(entry.Status) + .Append('@') + .Append(entry.Source); + } + + foreach (var path in sbom.DependencyPaths + .OrderBy(path => path.IsRuntime) + .ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal)) + { + builder.Append("|path:") + .Append(path.IsRuntime ? 'R' : 'D'); + + foreach (var node in path.Nodes) + { + builder.Append(":") + .Append(node.Identifier) + .Append('@') + .Append(node.Version ?? string.Empty); + } + + if (!string.IsNullOrWhiteSpace(path.Source)) + { + builder.Append("|pathsrc:").Append(path.Source); + } + + if (!path.Metadata.IsEmpty) + { + foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("|pathmeta:") + .Append(kvp.Key) + .Append('=') + .Append(kvp.Value); + } + } + } + + if (!sbom.EnvironmentFlags.IsEmpty) + { + foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("|env:") + .Append(flag.Key) + .Append('=') + .Append(flag.Value); + } + } + + if (sbom.BlastRadius is not null) + { + builder.Append("|blast:") + .Append(sbom.BlastRadius.ImpactedAssets) + .Append(',') + .Append(sbom.BlastRadius.ImpactedWorkloads) + .Append(',') + .Append(sbom.BlastRadius.ImpactedNamespaces) + .Append(',') + .Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty); + + if (!sbom.BlastRadius.Metadata.IsEmpty) + { + foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("|blastmeta:") + .Append(kvp.Key) + .Append('=') + .Append(kvp.Value); + } + } + } + + if (!sbom.Metadata.IsEmpty) + { + foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("|sbommeta:") + .Append(kvp.Key) + .Append('=') + .Append(kvp.Value); + } + } + } + + if (dependency is not null) + { + foreach (var node in dependency.Nodes + .OrderBy(n => n.Identifier, StringComparer.Ordinal)) + { + builder.Append("|dep:") + .Append(node.Identifier) + .Append(':') + .Append(node.RuntimeOccurrences) + .Append(':') + .Append(node.DevelopmentOccurrences) + .Append(':') + .Append(string.Join(',', node.Versions)); + } + + if (!dependency.Metadata.IsEmpty) + { + foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("|depmeta:") + .Append(kvp.Key) + .Append('=') + .Append(kvp.Value); + } + } + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryTaskPlan.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryTaskPlan.cs index 47dcf7be..ded06acf 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryTaskPlan.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Orchestration/AdvisoryTaskPlan.cs @@ -1,70 +1,70 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Documents; using StellaOps.AdvisoryAI.Tools; - -namespace StellaOps.AdvisoryAI.Orchestration; - -public sealed class AdvisoryTaskPlan -{ - public AdvisoryTaskPlan( - AdvisoryTaskRequest request, - string cacheKey, - string promptTemplate, - ImmutableArray structuredChunks, - ImmutableArray vectorResults, - SbomContextResult? sbomContext, - DependencyAnalysisResult? dependencyAnalysis, + +namespace StellaOps.AdvisoryAI.Orchestration; + +public sealed class AdvisoryTaskPlan +{ + public AdvisoryTaskPlan( + AdvisoryTaskRequest request, + string cacheKey, + string promptTemplate, + ImmutableArray structuredChunks, + ImmutableArray vectorResults, + SbomContextResult? sbomContext, + DependencyAnalysisResult? dependencyAnalysis, AdvisoryTaskBudget budget, ImmutableDictionary metadata) - { - Request = request ?? throw new ArgumentNullException(nameof(request)); - CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey)); - PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate)); - StructuredChunks = structuredChunks; - VectorResults = vectorResults; - SbomContext = sbomContext; - DependencyAnalysis = dependencyAnalysis; - Budget = budget ?? throw new ArgumentNullException(nameof(budget)); - Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); - } - - public AdvisoryTaskRequest Request { get; } - - public string CacheKey { get; } - - public string PromptTemplate { get; } - - public ImmutableArray StructuredChunks { get; } - - public ImmutableArray VectorResults { get; } - - public SbomContextResult? SbomContext { get; } - - public DependencyAnalysisResult? DependencyAnalysis { get; } - - public AdvisoryTaskBudget Budget { get; } - + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey)); + PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate)); + StructuredChunks = structuredChunks; + VectorResults = vectorResults; + SbomContext = sbomContext; + DependencyAnalysis = dependencyAnalysis; + Budget = budget ?? throw new ArgumentNullException(nameof(budget)); + Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + public AdvisoryTaskRequest Request { get; } + + public string CacheKey { get; } + + public string PromptTemplate { get; } + + public ImmutableArray StructuredChunks { get; } + + public ImmutableArray VectorResults { get; } + + public SbomContextResult? SbomContext { get; } + + public DependencyAnalysisResult? DependencyAnalysis { get; } + + public AdvisoryTaskBudget Budget { get; } + public ImmutableDictionary Metadata { get; } -} - -public sealed class AdvisoryVectorResult -{ - public AdvisoryVectorResult(string query, ImmutableArray matches) - { - Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query; - Matches = matches; - } - - public string Query { get; } - - public ImmutableArray Matches { get; } -} - -public sealed class AdvisoryTaskBudget -{ - public int PromptTokens { get; init; } = 2048; - - public int CompletionTokens { get; init; } = 512; -} +} + +public sealed class AdvisoryVectorResult +{ + public AdvisoryVectorResult(string query, ImmutableArray matches) + { + Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query; + Matches = matches; + } + + public string Query { get; } + + public ImmutableArray Matches { get; } +} + +public sealed class AdvisoryTaskBudget +{ + public int PromptTokens { get; init; } = 2048; + + public int CompletionTokens { get; init; } = 512; +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextClientOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextClientOptions.cs index 27c1974d..000abe81 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextClientOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextClientOptions.cs @@ -1,30 +1,30 @@ -using System; - -namespace StellaOps.AdvisoryAI.Providers; - -/// -/// Configuration for the SBOM context HTTP client. -/// -public sealed class SbomContextClientOptions -{ - /// - /// Base address for the SBOM service. Required. - /// - public Uri? BaseAddress { get; set; } - - /// - /// Relative endpoint that returns SBOM context payloads. - /// Defaults to api/sbom/context. - /// - public string ContextEndpoint { get; set; } = "api/sbom/context"; - - /// - /// Optional tenant identifier that should be forwarded to the SBOM service. - /// - public string? Tenant { get; set; } - - /// - /// Header name used when forwarding the tenant. Defaults to X-StellaOps-Tenant. - /// - public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant"; -} +using System; + +namespace StellaOps.AdvisoryAI.Providers; + +/// +/// Configuration for the SBOM context HTTP client. +/// +public sealed class SbomContextClientOptions +{ + /// + /// Base address for the SBOM service. Required. + /// + public Uri? BaseAddress { get; set; } + + /// + /// Relative endpoint that returns SBOM context payloads. + /// Defaults to api/sbom/context. + /// + public string ContextEndpoint { get; set; } = "api/sbom/context"; + + /// + /// Optional tenant identifier that should be forwarded to the SBOM service. + /// + public string? Tenant { get; set; } + + /// + /// Header name used when forwarding the tenant. Defaults to X-StellaOps-Tenant. + /// + public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant"; +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextHttpClient.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextHttpClient.cs index ed825060..fbd19ec1 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextHttpClient.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Providers/SbomContextHttpClient.cs @@ -1,234 +1,234 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Globalization; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace StellaOps.AdvisoryAI.Providers; - -internal sealed class SbomContextHttpClient : ISbomContextClient -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - PropertyNameCaseInsensitive = true - }; - - private readonly HttpClient httpClient; - private readonly SbomContextClientOptions options; - private readonly ILogger? logger; - - public SbomContextHttpClient( - HttpClient httpClient, - IOptions options, - ILogger? logger = null) - { - this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - this.options = options.Value ?? throw new ArgumentNullException(nameof(options)); - - if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null) - { - this.httpClient.BaseAddress = this.options.BaseAddress; - } - - if (this.httpClient.BaseAddress is null) - { - throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured."); - } - - this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); - this.logger = logger; - } - - public async Task GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken) - { - if (query is null) - { - throw new ArgumentNullException(nameof(query)); - } - - var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty; - if (endpoint.Length == 0) - { - throw new InvalidOperationException("SBOM context endpoint must be configured."); - } - - var requestUri = BuildRequestUri(endpoint, query); - using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - ApplyTenantHeader(request); - - using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent) - { - logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri); - return null; - } - - if (!response.IsSuccessStatusCode) - { - var content = response.Content is null - ? string.Empty - : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - - logger?.LogWarning( - "SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}", - requestUri, - (int)response.StatusCode, - content); - - response.EnsureSuccessStatusCode(); - } - +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.AdvisoryAI.Providers; + +internal sealed class SbomContextHttpClient : ISbomContextClient +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient httpClient; + private readonly SbomContextClientOptions options; + private readonly ILogger? logger; + + public SbomContextHttpClient( + HttpClient httpClient, + IOptions options, + ILogger? logger = null) + { + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + this.options = options.Value ?? throw new ArgumentNullException(nameof(options)); + + if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null) + { + this.httpClient.BaseAddress = this.options.BaseAddress; + } + + if (this.httpClient.BaseAddress is null) + { + throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured."); + } + + this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + this.logger = logger; + } + + public async Task GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty; + if (endpoint.Length == 0) + { + throw new InvalidOperationException("SBOM context endpoint must be configured."); + } + + var requestUri = BuildRequestUri(endpoint, query); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + ApplyTenantHeader(request); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent) + { + logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri); + return null; + } + + if (!response.IsSuccessStatusCode) + { + var content = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + logger?.LogWarning( + "SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}", + requestUri, + (int)response.StatusCode, + content); + + response.EnsureSuccessStatusCode(); + } + var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content."); var payload = await httpContent.ReadFromJsonAsync(SerializerOptions, cancellationToken: cancellationToken) .ConfigureAwait(false); - - if (payload is null) - { - logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri); - return null; - } - - return payload.ToDocument(); - } - - private Uri BuildRequestUri(string endpoint, SbomContextQuery query) - { - var relative = endpoint.StartsWith("/", StringComparison.Ordinal) - ? endpoint[1..] - : endpoint; - - var queryBuilder = new StringBuilder(); - - AppendQuery(queryBuilder, "artifactId", query.ArtifactId); - AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture)); - AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture)); - AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false"); - AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false"); - - if (!string.IsNullOrWhiteSpace(query.Purl)) - { - AppendQuery(queryBuilder, "purl", query.Purl!); - } - - var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative; - return new Uri(httpClient.BaseAddress!, uriString); - - static void AppendQuery(StringBuilder builder, string name, string value) - { - if (builder.Length > 0) - { - builder.Append('&'); - } - - builder.Append(Uri.EscapeDataString(name)); - builder.Append('='); - builder.Append(Uri.EscapeDataString(value)); - } - } - - private void ApplyTenantHeader(HttpRequestMessage request) - { - if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName)) - { - return; - } - - if (!request.Headers.Contains(options.TenantHeaderName)) - { - request.Headers.Add(options.TenantHeaderName, options.Tenant); - } - } - - private sealed record SbomContextPayload( - [property: JsonPropertyName("artifactId")] string ArtifactId, - [property: JsonPropertyName("purl")] string? Purl, - [property: JsonPropertyName("versions")] ImmutableArray Versions, - [property: JsonPropertyName("dependencyPaths")] ImmutableArray DependencyPaths, - [property: JsonPropertyName("environmentFlags")] ImmutableDictionary EnvironmentFlags, - [property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius, - [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) - { - public SbomContextDocument ToDocument() - => new( - ArtifactId, - Purl, - Versions.IsDefault ? ImmutableArray.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(), - DependencyPaths.IsDefault ? ImmutableArray.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(), - EnvironmentFlags == default ? ImmutableDictionary.Empty : EnvironmentFlags, - BlastRadius?.ToRecord(), - Metadata == default ? ImmutableDictionary.Empty : Metadata); - } - - private sealed record SbomVersionPayload( - [property: JsonPropertyName("version")] string Version, - [property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved, - [property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("source")] string Source, - [property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable, - [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) - { - public SbomVersionRecord ToRecord() - => new( - Version, - FirstObserved, - LastObserved, - Status, - Source, - IsFixAvailable, - Metadata == default ? ImmutableDictionary.Empty : Metadata); - } - - private sealed record SbomDependencyPathPayload( - [property: JsonPropertyName("nodes")] ImmutableArray Nodes, - [property: JsonPropertyName("isRuntime")] bool IsRuntime, - [property: JsonPropertyName("source")] string? Source, - [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) - { - public SbomDependencyPathRecord ToRecord() - => new( - Nodes.IsDefault ? ImmutableArray.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(), - IsRuntime, - Source, - Metadata == default ? ImmutableDictionary.Empty : Metadata); - } - - private sealed record SbomDependencyNodePayload( - [property: JsonPropertyName("identifier")] string Identifier, - [property: JsonPropertyName("version")] string? Version) - { - public SbomDependencyNodeRecord ToRecord() - => new(Identifier, Version); - } - - private sealed record SbomBlastRadiusPayload( - [property: JsonPropertyName("impactedAssets")] int ImpactedAssets, - [property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads, - [property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces, - [property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage, - [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) - { - public SbomBlastRadiusRecord ToRecord() - => new( - ImpactedAssets, - ImpactedWorkloads, - ImpactedNamespaces, - ImpactedPercentage, - Metadata == default ? ImmutableDictionary.Empty : Metadata); - } -} + + if (payload is null) + { + logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri); + return null; + } + + return payload.ToDocument(); + } + + private Uri BuildRequestUri(string endpoint, SbomContextQuery query) + { + var relative = endpoint.StartsWith("/", StringComparison.Ordinal) + ? endpoint[1..] + : endpoint; + + var queryBuilder = new StringBuilder(); + + AppendQuery(queryBuilder, "artifactId", query.ArtifactId); + AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture)); + AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture)); + AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false"); + AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false"); + + if (!string.IsNullOrWhiteSpace(query.Purl)) + { + AppendQuery(queryBuilder, "purl", query.Purl!); + } + + var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative; + return new Uri(httpClient.BaseAddress!, uriString); + + static void AppendQuery(StringBuilder builder, string name, string value) + { + if (builder.Length > 0) + { + builder.Append('&'); + } + + builder.Append(Uri.EscapeDataString(name)); + builder.Append('='); + builder.Append(Uri.EscapeDataString(value)); + } + } + + private void ApplyTenantHeader(HttpRequestMessage request) + { + if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName)) + { + return; + } + + if (!request.Headers.Contains(options.TenantHeaderName)) + { + request.Headers.Add(options.TenantHeaderName, options.Tenant); + } + } + + private sealed record SbomContextPayload( + [property: JsonPropertyName("artifactId")] string ArtifactId, + [property: JsonPropertyName("purl")] string? Purl, + [property: JsonPropertyName("versions")] ImmutableArray Versions, + [property: JsonPropertyName("dependencyPaths")] ImmutableArray DependencyPaths, + [property: JsonPropertyName("environmentFlags")] ImmutableDictionary EnvironmentFlags, + [property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius, + [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) + { + public SbomContextDocument ToDocument() + => new( + ArtifactId, + Purl, + Versions.IsDefault ? ImmutableArray.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(), + DependencyPaths.IsDefault ? ImmutableArray.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(), + EnvironmentFlags == default ? ImmutableDictionary.Empty : EnvironmentFlags, + BlastRadius?.ToRecord(), + Metadata == default ? ImmutableDictionary.Empty : Metadata); + } + + private sealed record SbomVersionPayload( + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved, + [property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable, + [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) + { + public SbomVersionRecord ToRecord() + => new( + Version, + FirstObserved, + LastObserved, + Status, + Source, + IsFixAvailable, + Metadata == default ? ImmutableDictionary.Empty : Metadata); + } + + private sealed record SbomDependencyPathPayload( + [property: JsonPropertyName("nodes")] ImmutableArray Nodes, + [property: JsonPropertyName("isRuntime")] bool IsRuntime, + [property: JsonPropertyName("source")] string? Source, + [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) + { + public SbomDependencyPathRecord ToRecord() + => new( + Nodes.IsDefault ? ImmutableArray.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(), + IsRuntime, + Source, + Metadata == default ? ImmutableDictionary.Empty : Metadata); + } + + private sealed record SbomDependencyNodePayload( + [property: JsonPropertyName("identifier")] string Identifier, + [property: JsonPropertyName("version")] string? Version) + { + public SbomDependencyNodeRecord ToRecord() + => new(Identifier, Version); + } + + private sealed record SbomBlastRadiusPayload( + [property: JsonPropertyName("impactedAssets")] int ImpactedAssets, + [property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads, + [property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces, + [property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage, + [property: JsonPropertyName("metadata")] ImmutableDictionary Metadata) + { + public SbomBlastRadiusRecord ToRecord() + => new( + ImpactedAssets, + ImpactedWorkloads, + ImpactedNamespaces, + ImpactedPercentage, + Metadata == default ? ImmutableDictionary.Empty : Metadata); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj b/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj index 1842bb43..9e28fa13 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj @@ -1,20 +1,20 @@ - - - - net10.0 - preview - enable - enable - true - + + + + net10.0 + preview + enable + enable + true + - - - - - - + + + + + + diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index a6a42d5e..b568dc03 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -4,12 +4,12 @@ | AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. | | AIAI-31-002 | DONE (2025-11-04) | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. | | AIAI-31-003 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. | -| AIAI-31-004 | DOING | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. | -| AIAI-31-004A | DOING (2025-11-04) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. | +| AIAI-31-004 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. | +| AIAI-31-004A | DONE (2025-11-04) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. | | AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. | | AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run ` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. | -| AIAI-31-005 | DOING (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. | -| AIAI-31-006 | DOING (2025-11-03) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. | +| AIAI-31-005 | DONE (2025-11-04) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. | +| AIAI-31-006 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. | | AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. | | AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. | | AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. | @@ -23,5 +23,11 @@ > 2025-11-04: AIAI-31-003 completed – toolset wired via DI/orchestrator, SBOM context client available, and unit coverage for compare/range/dependency analysis extended. > 2025-11-02: AIAI-31-004 started orchestration pipeline work – begin designing summary/conflict/remediation workflow (deterministic sequence + cache keys). +> 2025-11-04: AIAI-31-004 DONE – orchestrator composes structured/vector/SBOM context with stable cache keys and metadata (env flags, blast radius, dependency metrics); unit coverage via `AdvisoryPipelineOrchestratorTests` keeps determinism enforced. > 2025-11-02: AIAI-31-004 orchestration prerequisites documented in docs/modules/advisory-ai/orchestration-pipeline.md (task breakdown 004A/004B/004C). +> 2025-11-04: AIAI-31-004A DONE – WebService `/v1/advisory-ai/pipeline/*` + batch endpoints enqueue plans with rate limiting & scope headers, Worker drains filesystem queue, metrics/logging added, docs updated. Tests: `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-restore`. + +> 2025-11-04: AIAI-31-005 DONE – guardrail pipeline redacts secrets, enforces citation/injection policies, emits block counters, and tests (`AdvisoryGuardrailPipelineTests`) cover redaction + citation validation. + +> 2025-11-04: AIAI-31-006 DONE – REST endpoints enforce header scopes, apply token bucket rate limiting, sanitize prompts via guardrails, and queue execution with cached metadata. Tests executed via `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-restore`. diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/DeterministicToolsetTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/DeterministicToolsetTests.cs index 80c05949..36e30ecf 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/DeterministicToolsetTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/DeterministicToolsetTests.cs @@ -1,79 +1,79 @@ -using System.Collections.Immutable; -using System.Linq; -using FluentAssertions; -using StellaOps.AdvisoryAI.Context; -using StellaOps.AdvisoryAI.Tools; -using Xunit; - -namespace StellaOps.AdvisoryAI.Tests; - -public sealed class DeterministicToolsetTests -{ - [Fact] - public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts() - { - var context = SbomContextResult.Create( - "artifact-123", - purl: null, - versionTimeline: Array.Empty(), - dependencyPaths: new[] - { - new SbomDependencyPath( - new[] - { - new SbomDependencyNode("root", "1.0.0"), - new SbomDependencyNode("lib-a", "2.0.0"), - }, - isRuntime: true), - new SbomDependencyPath( - new[] - { - new SbomDependencyNode("root", "1.0.0"), - new SbomDependencyNode("lib-b", "3.1.4"), - }, - isRuntime: false), - }); - - IDeterministicToolset toolset = new DeterministicToolset(); - var analysis = toolset.AnalyzeDependencies(context); - - analysis.ArtifactId.Should().Be("artifact-123"); - analysis.Metadata["path_count"].Should().Be("2"); - analysis.Metadata["runtime_path_count"].Should().Be("1"); - analysis.Metadata["development_path_count"].Should().Be("1"); - analysis.Nodes.Should().HaveCount(3); - - var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a"); - libA.RuntimeOccurrences.Should().Be(1); - libA.DevelopmentOccurrences.Should().Be(0); - - var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b"); - libB.RuntimeOccurrences.Should().Be(0); - libB.DevelopmentOccurrences.Should().Be(1); - } - - [Theory] - [InlineData("semver", "1.2.3", "1.2.4", -1)] - [InlineData("semver", "1.2.3", "1.2.3", 0)] - [InlineData("semver", "1.2.4", "1.2.3", 1)] - [InlineData("evr", "1:1.0-1", "1:1.0-2", -1)] - [InlineData("evr", "0:2.0-0", "0:2.0-0", 0)] - [InlineData("evr", "0:2.1-0", "0:2.0-5", 1)] - public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected) - { - IDeterministicToolset toolset = new DeterministicToolset(); - toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue(); - comparison.Should().Be(expected); - } - - [Theory] - [InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")] - [InlineData("semver", "2.0.0", ">=2.0.0")] - [InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")] - [InlineData("evr", "1:3.4-1", ">=1:3.0-0")] - public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range) - { - IDeterministicToolset toolset = new DeterministicToolset(); - toolset.SatisfiesRange(scheme, version, range).Should().BeTrue(); - } -} +using System.Collections.Immutable; +using System.Linq; +using FluentAssertions; +using StellaOps.AdvisoryAI.Context; +using StellaOps.AdvisoryAI.Tools; +using Xunit; + +namespace StellaOps.AdvisoryAI.Tests; + +public sealed class DeterministicToolsetTests +{ + [Fact] + public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts() + { + var context = SbomContextResult.Create( + "artifact-123", + purl: null, + versionTimeline: Array.Empty(), + dependencyPaths: new[] + { + new SbomDependencyPath( + new[] + { + new SbomDependencyNode("root", "1.0.0"), + new SbomDependencyNode("lib-a", "2.0.0"), + }, + isRuntime: true), + new SbomDependencyPath( + new[] + { + new SbomDependencyNode("root", "1.0.0"), + new SbomDependencyNode("lib-b", "3.1.4"), + }, + isRuntime: false), + }); + + IDeterministicToolset toolset = new DeterministicToolset(); + var analysis = toolset.AnalyzeDependencies(context); + + analysis.ArtifactId.Should().Be("artifact-123"); + analysis.Metadata["path_count"].Should().Be("2"); + analysis.Metadata["runtime_path_count"].Should().Be("1"); + analysis.Metadata["development_path_count"].Should().Be("1"); + analysis.Nodes.Should().HaveCount(3); + + var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a"); + libA.RuntimeOccurrences.Should().Be(1); + libA.DevelopmentOccurrences.Should().Be(0); + + var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b"); + libB.RuntimeOccurrences.Should().Be(0); + libB.DevelopmentOccurrences.Should().Be(1); + } + + [Theory] + [InlineData("semver", "1.2.3", "1.2.4", -1)] + [InlineData("semver", "1.2.3", "1.2.3", 0)] + [InlineData("semver", "1.2.4", "1.2.3", 1)] + [InlineData("evr", "1:1.0-1", "1:1.0-2", -1)] + [InlineData("evr", "0:2.0-0", "0:2.0-0", 0)] + [InlineData("evr", "0:2.1-0", "0:2.0-5", 1)] + public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected) + { + IDeterministicToolset toolset = new DeterministicToolset(); + toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue(); + comparison.Should().Be(expected); + } + + [Theory] + [InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")] + [InlineData("semver", "2.0.0", ">=2.0.0")] + [InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")] + [InlineData("evr", "1:3.4-1", ">=1:3.0-0")] + public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range) + { + IDeterministicToolset toolset = new DeterministicToolset(); + toolset.SatisfiesRange(scheme, version, range).Should().BeTrue(); + } +} diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/SbomContextHttpClientTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/SbomContextHttpClientTests.cs index 475f5747..df32b409 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/SbomContextHttpClientTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/SbomContextHttpClientTests.cs @@ -1,144 +1,144 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StellaOps.AdvisoryAI.Providers; -using Xunit; - -namespace StellaOps.AdvisoryAI.Tests; - -public sealed class SbomContextHttpClientTests -{ - [Fact] - public async Task GetContextAsync_MapsPayloadToDocument() - { - const string payload = """ - { - "artifactId": "artifact-001", - "purl": "pkg:npm/react@18.3.0", - "versions": [ - { - "version": "18.3.0", - "firstObserved": "2025-10-01T00:00:00Z", - "lastObserved": null, - "status": "affected", - "source": "inventory", - "isFixAvailable": false, - "metadata": { "note": "current" } - } - ], - "dependencyPaths": [ - { - "nodes": [ - { "identifier": "app", "version": "1.0.0" }, - { "identifier": "react", "version": "18.3.0" } - ], - "isRuntime": true, - "source": "scanner", - "metadata": { "scope": "production" } - } - ], - "environmentFlags": { - "environment/prod": "true" - }, - "blastRadius": { - "impactedAssets": 10, - "impactedWorkloads": 4, - "impactedNamespaces": 2, - "impactedPercentage": 0.25, - "metadata": { "note": "simulated" } - }, - "metadata": { - "source": "sbom-service" - } - } - """; - - var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(payload, Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://sbom.example/") - }; - - var options = Options.Create(new SbomContextClientOptions - { - ContextEndpoint = "api/sbom/context", - Tenant = "tenant-alpha", - TenantHeaderName = "X-StellaOps-Tenant" - }); - - var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); - - var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true); - var document = await client.GetContextAsync(query, CancellationToken.None); - - Assert.NotNull(document); - Assert.Equal("artifact-001", document!.ArtifactId); - Assert.Equal("pkg:npm/react@18.3.0", document.Purl); +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.AdvisoryAI.Providers; +using Xunit; + +namespace StellaOps.AdvisoryAI.Tests; + +public sealed class SbomContextHttpClientTests +{ + [Fact] + public async Task GetContextAsync_MapsPayloadToDocument() + { + const string payload = """ + { + "artifactId": "artifact-001", + "purl": "pkg:npm/react@18.3.0", + "versions": [ + { + "version": "18.3.0", + "firstObserved": "2025-10-01T00:00:00Z", + "lastObserved": null, + "status": "affected", + "source": "inventory", + "isFixAvailable": false, + "metadata": { "note": "current" } + } + ], + "dependencyPaths": [ + { + "nodes": [ + { "identifier": "app", "version": "1.0.0" }, + { "identifier": "react", "version": "18.3.0" } + ], + "isRuntime": true, + "source": "scanner", + "metadata": { "scope": "production" } + } + ], + "environmentFlags": { + "environment/prod": "true" + }, + "blastRadius": { + "impactedAssets": 10, + "impactedWorkloads": 4, + "impactedNamespaces": 2, + "impactedPercentage": 0.25, + "metadata": { "note": "simulated" } + }, + "metadata": { + "source": "sbom-service" + } + } + """; + + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://sbom.example/") + }; + + var options = Options.Create(new SbomContextClientOptions + { + ContextEndpoint = "api/sbom/context", + Tenant = "tenant-alpha", + TenantHeaderName = "X-StellaOps-Tenant" + }); + + var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); + + var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true); + var document = await client.GetContextAsync(query, CancellationToken.None); + + Assert.NotNull(document); + Assert.Equal("artifact-001", document!.ArtifactId); + Assert.Equal("pkg:npm/react@18.3.0", document.Purl); Assert.Single(document.Versions); - Assert.Single(document.DependencyPaths); - Assert.Single(document.EnvironmentFlags); - Assert.NotNull(document.BlastRadius); - Assert.Equal("sbom-service", document.Metadata["source"]); - - Assert.NotNull(handler.LastRequest); - Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single()); - Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query); - Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query); - Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query); - Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query); - } - - [Fact] - public async Task GetContextAsync_ReturnsNullOnNotFound() - { - var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; - var options = Options.Create(new SbomContextClientOptions()); - var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); - - var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None); - Assert.Null(result); - } - - [Fact] - public async Task GetContextAsync_ThrowsForServerError() - { - var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json") - }); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; - var options = Options.Create(new SbomContextClientOptions()); - var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); - - await Assert.ThrowsAsync(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None)); - } - - private sealed class StubHttpMessageHandler : HttpMessageHandler - { - private readonly Func responder; - - public StubHttpMessageHandler(Func responder) - { - this.responder = responder ?? throw new ArgumentNullException(nameof(responder)); - } - - public HttpRequestMessage? LastRequest { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - return Task.FromResult(responder(request)); - } - } -} + Assert.Single(document.DependencyPaths); + Assert.Single(document.EnvironmentFlags); + Assert.NotNull(document.BlastRadius); + Assert.Equal("sbom-service", document.Metadata["source"]); + + Assert.NotNull(handler.LastRequest); + Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single()); + Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query); + Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query); + Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query); + Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query); + } + + [Fact] + public async Task GetContextAsync_ReturnsNullOnNotFound() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; + var options = Options.Create(new SbomContextClientOptions()); + var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); + + var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task GetContextAsync_ThrowsForServerError() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; + var options = Options.Create(new SbomContextClientOptions()); + var client = new SbomContextHttpClient(httpClient, options, NullLogger.Instance); + + await Assert.ThrowsAsync(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None)); + } + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Func responder; + + public StubHttpMessageHandler(Func responder) + { + this.responder = responder ?? throw new ArgumentNullException(nameof(responder)); + } + + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(responder(request)); + } + } +} diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj index 41b51561..9765e7ce 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj @@ -1,29 +1,29 @@ - - - - net10.0 - preview - false - enable - enable - - + + + + net10.0 + preview + false + enable + enable + + - - - + + + - - - - - - - PreserveNewest - - - PreserveNewest - - - + + + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ToolsetServiceCollectionExtensionsTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ToolsetServiceCollectionExtensionsTests.cs index 996c381c..810438e7 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ToolsetServiceCollectionExtensionsTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/ToolsetServiceCollectionExtensionsTests.cs @@ -10,30 +10,30 @@ using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Documents; using Xunit; - -namespace StellaOps.AdvisoryAI.Tests; - -public sealed class ToolsetServiceCollectionExtensionsTests -{ - [Fact] - public void AddAdvisoryDeterministicToolset_RegistersSingleton() - { - var services = new ServiceCollection(); - - services.AddAdvisoryDeterministicToolset(); - - var provider = services.BuildServiceProvider(); - var toolsetA = provider.GetRequiredService(); - var toolsetB = provider.GetRequiredService(); - - Assert.Same(toolsetA, toolsetB); - } - - [Fact] - public void AddAdvisoryPipeline_RegistersOrchestrator() - { - var services = new ServiceCollection(); - + +namespace StellaOps.AdvisoryAI.Tests; + +public sealed class ToolsetServiceCollectionExtensionsTests +{ + [Fact] + public void AddAdvisoryDeterministicToolset_RegistersSingleton() + { + var services = new ServiceCollection(); + + services.AddAdvisoryDeterministicToolset(); + + var provider = services.BuildServiceProvider(); + var toolsetA = provider.GetRequiredService(); + var toolsetB = provider.GetRequiredService(); + + Assert.Same(toolsetA, toolsetB); + } + + [Fact] + public void AddAdvisoryPipeline_RegistersOrchestrator() + { + var services = new ServiceCollection(); + services.AddSbomContext(options => { options.BaseAddress = new Uri("https://sbom.example/"); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md index d6518e88..b90fdef7 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md @@ -8,7 +8,7 @@ | 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.
⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. | | PLG7.RFC | DONE (2025-11-03) | 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. | | PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. | -| PLG7.IMPL-002 | DOING (2025-11-03) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented. | +| PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.
2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. | | PLG7.IMPL-003 | TODO | BE-Auth Plugin | PLG7.IMPL-001 | Deliver claims enricher with DN-to-role dictionary and regex mapping plus Mongo cache, including determinism + eviction tests. | ✅ Regex mapping deterministic; ✅ Cache TTL + invalidation tested; ✅ Claims doc updated. | | PLG7.IMPL-004 | TODO | BE-Auth Plugin, DevOps Guild | PLG7.IMPL-002 | Implement client provisioning store with LDAP write toggles, Mongo audit mirror, bootstrap validation, and health reporting. | ✅ Audit mirror records persisted; ✅ Bootstrap validation logs capability summary; ✅ Health checks cover LDAP + audit mirror. | | PLG7.IMPL-005 | TODO | BE-Auth Plugin, Docs Guild | PLG7.IMPL-001..004 | Update developer guide, samples, and release notes for LDAP plugin (mutual TLS, regex mapping, audit mirror) and ensure Offline Kit coverage. | ✅ Docs merged; ✅ Release notes drafted; ✅ Offline kit config templates updated. | diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs index 5fd4f9ab..5eae0d39 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs @@ -1,282 +1,282 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; -using System.Net.Http.Headers; -using Microsoft.Extensions.DependencyInjection; -using MongoDB.Bson; -using MongoDB.Driver; -using StellaOps.Authority.Tests.Infrastructure; -using StellaOps.Auth.Abstractions; -using StellaOps.Configuration; -using Xunit; -using Microsoft.AspNetCore.TestHost; - -namespace StellaOps.Authority.Tests.AdvisoryAi; - -public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture -{ - private readonly AuthorityWebApplicationFactory factory; - - public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory) - { - this.factory = factory; - } - - [Fact] - public async Task RemoteInference_ReturnsForbidden_WhenDisabled() - { - using var client = CreateClient( - configureOptions: options => - { - options.AdvisoryAi.RemoteInference.Enabled = false; - options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); - options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); - }); - - var response = await client.PostAsJsonAsync( - "/advisory-ai/remote-inference/logs", - CreatePayload(profile: "cloud-openai")); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(body); - Assert.Equal("remote_inference_disabled", body!["error"]); - } - - [Fact] - public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing() - { - using var client = CreateClient( - configureOptions: options => - { - SeedRemoteInferenceEnabled(options); - SeedTenantConsent(options); - options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false; - options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null; - options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null; - options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null; - }); - - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - - var response = await client.PostAsJsonAsync( - "/advisory-ai/remote-inference/logs", - CreatePayload(profile: "cloud-openai")); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(body); - Assert.Equal("remote_inference_consent_required", body!["error"]); - } - - [Fact] - public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed() - { - using var client = CreateClient( - configureOptions: options => - { - SeedRemoteInferenceEnabled(options); - SeedTenantConsent(options); - }); - - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - - var response = await client.PostAsJsonAsync( - "/advisory-ai/remote-inference/logs", - CreatePayload(profile: "other-profile")); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(body); - Assert.Equal("profile_not_allowed", body!["error"]); - } - - [Fact] - public async Task RemoteInference_LogsPrompt_WhenConsentGranted() - { - using var client = CreateClient( - configureOptions: options => - { - SeedRemoteInferenceEnabled(options); - SeedTenantConsent(options); - }); - - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - - var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests"); - var collection = database.GetCollection("authority_login_attempts"); - await collection.DeleteManyAsync(FilterDefinition.Empty); - - var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan."); - var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(body); - Assert.Equal("logged", body!["status"]); - - var expectedHash = ComputeSha256(payload.Prompt); - Assert.Equal(expectedHash, body["prompt_hash"]); - - var doc = await collection.Find(Builders.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync(); - Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString); - - var properties = ExtractProperties(doc); - Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]); - Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]); - Assert.Equal(payload.Profile, properties["advisory_ai.profile"]); - Assert.False(properties.ContainsKey("advisory_ai.prompt.raw")); - } - - private HttpClient CreateClient(Action? configureOptions = null) - { - const string schemeName = "StellaOpsBearer"; - - var builder = factory.WithWebHostBuilder(hostBuilder => - { - hostBuilder.ConfigureTestServices(services => - { - services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = schemeName; - options.DefaultChallengeScheme = schemeName; - }) - .AddScheme(schemeName, _ => { }); - - services.PostConfigure(opts => - { - opts.Issuer ??= new Uri("https://authority.test"); - if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString)) - { - opts.Storage.ConnectionString = factory.ConnectionString; - } - - if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName)) - { - opts.Storage.DatabaseName = "authority-tests"; - } - - opts.AdvisoryAi.RemoteInference.Enabled = true; - opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true; - opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); - opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); - - opts.Tenants.Clear(); - opts.Tenants.Add(new AuthorityTenantOptions - { - Id = "tenant-default", - DisplayName = "Tenant Default", - AdvisoryAi = - { - RemoteInference = - { - ConsentGranted = true, - ConsentVersion = "2025-10", - ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"), - ConsentedBy = "legal@example.com" - } - } - }); - - configureOptions?.Invoke(opts); - }); - }); - }); - - var client = builder.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName); - return client; - } - - private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options) - { - options.AdvisoryAi.RemoteInference.Enabled = true; - options.AdvisoryAi.RemoteInference.RequireTenantConsent = true; - options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); - options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); - } - - private static void SeedTenantConsent(StellaOpsAuthorityOptions options) - { - if (options.Tenants.Count == 0) - { - options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" }); - } - - var tenant = options.Tenants[0]; - tenant.Id = "tenant-default"; - tenant.DisplayName = "Tenant Default"; - tenant.AdvisoryAi.RemoteInference.ConsentGranted = true; - tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10"; - tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"); - tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com"; - } - - private static string ComputeSha256(string value) - { - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private static Dictionary ExtractProperties(BsonDocument document) - { - var result = new Dictionary(StringComparer.Ordinal); - if (!document.TryGetValue("properties", out var propertiesValue)) - { - return result; - } - - foreach (var item in propertiesValue.AsBsonArray) - { - if (item is not BsonDocument property) - { - continue; - } - - var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null; - var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null; - - if (!string.IsNullOrWhiteSpace(name)) - { - result[name] = value ?? string.Empty; - } - } - - return result; - } - - private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.") - { - return new RemoteInferencePayload( - TaskType: "summary", - Profile: profile, - ModelId: "gpt-4o-mini", - Prompt: prompt, - ContextDigest: "sha256:context", - OutputHash: "sha256:output", - TaskId: "task-123", - Metadata: new Dictionary - { - ["channel"] = "cli", - ["env"] = "test" - }); - } - - private sealed record RemoteInferencePayload( - [property: JsonPropertyName("taskType")] string TaskType, - [property: JsonPropertyName("profile")] string Profile, - [property: JsonPropertyName("modelId")] string ModelId, - [property: JsonPropertyName("prompt")] string Prompt, - [property: JsonPropertyName("contextDigest")] string ContextDigest, - [property: JsonPropertyName("outputHash")] string OutputHash, - [property: JsonPropertyName("taskId")] string TaskId, - [property: JsonPropertyName("metadata")] IDictionary Metadata); -} +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Auth.Abstractions; +using StellaOps.Configuration; +using Xunit; +using Microsoft.AspNetCore.TestHost; + +namespace StellaOps.Authority.Tests.AdvisoryAi; + +public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture +{ + private readonly AuthorityWebApplicationFactory factory; + + public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory) + { + this.factory = factory; + } + + [Fact] + public async Task RemoteInference_ReturnsForbidden_WhenDisabled() + { + using var client = CreateClient( + configureOptions: options => + { + options.AdvisoryAi.RemoteInference.Enabled = false; + options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); + options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); + }); + + var response = await client.PostAsJsonAsync( + "/advisory-ai/remote-inference/logs", + CreatePayload(profile: "cloud-openai")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Equal("remote_inference_disabled", body!["error"]); + } + + [Fact] + public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing() + { + using var client = CreateClient( + configureOptions: options => + { + SeedRemoteInferenceEnabled(options); + SeedTenantConsent(options); + options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false; + options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null; + options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null; + options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null; + }); + + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + + var response = await client.PostAsJsonAsync( + "/advisory-ai/remote-inference/logs", + CreatePayload(profile: "cloud-openai")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Equal("remote_inference_consent_required", body!["error"]); + } + + [Fact] + public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed() + { + using var client = CreateClient( + configureOptions: options => + { + SeedRemoteInferenceEnabled(options); + SeedTenantConsent(options); + }); + + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + + var response = await client.PostAsJsonAsync( + "/advisory-ai/remote-inference/logs", + CreatePayload(profile: "other-profile")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Equal("profile_not_allowed", body!["error"]); + } + + [Fact] + public async Task RemoteInference_LogsPrompt_WhenConsentGranted() + { + using var client = CreateClient( + configureOptions: options => + { + SeedRemoteInferenceEnabled(options); + SeedTenantConsent(options); + }); + + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + + var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests"); + var collection = database.GetCollection("authority_login_attempts"); + await collection.DeleteManyAsync(FilterDefinition.Empty); + + var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan."); + var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Equal("logged", body!["status"]); + + var expectedHash = ComputeSha256(payload.Prompt); + Assert.Equal(expectedHash, body["prompt_hash"]); + + var doc = await collection.Find(Builders.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync(); + Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString); + + var properties = ExtractProperties(doc); + Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]); + Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]); + Assert.Equal(payload.Profile, properties["advisory_ai.profile"]); + Assert.False(properties.ContainsKey("advisory_ai.prompt.raw")); + } + + private HttpClient CreateClient(Action? configureOptions = null) + { + const string schemeName = "StellaOpsBearer"; + + var builder = factory.WithWebHostBuilder(hostBuilder => + { + hostBuilder.ConfigureTestServices(services => + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = schemeName; + options.DefaultChallengeScheme = schemeName; + }) + .AddScheme(schemeName, _ => { }); + + services.PostConfigure(opts => + { + opts.Issuer ??= new Uri("https://authority.test"); + if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString)) + { + opts.Storage.ConnectionString = factory.ConnectionString; + } + + if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName)) + { + opts.Storage.DatabaseName = "authority-tests"; + } + + opts.AdvisoryAi.RemoteInference.Enabled = true; + opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true; + opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); + opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); + + opts.Tenants.Clear(); + opts.Tenants.Add(new AuthorityTenantOptions + { + Id = "tenant-default", + DisplayName = "Tenant Default", + AdvisoryAi = + { + RemoteInference = + { + ConsentGranted = true, + ConsentVersion = "2025-10", + ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"), + ConsentedBy = "legal@example.com" + } + } + }); + + configureOptions?.Invoke(opts); + }); + }); + }); + + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName); + return client; + } + + private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options) + { + options.AdvisoryAi.RemoteInference.Enabled = true; + options.AdvisoryAi.RemoteInference.RequireTenantConsent = true; + options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear(); + options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai"); + } + + private static void SeedTenantConsent(StellaOpsAuthorityOptions options) + { + if (options.Tenants.Count == 0) + { + options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" }); + } + + var tenant = options.Tenants[0]; + tenant.Id = "tenant-default"; + tenant.DisplayName = "Tenant Default"; + tenant.AdvisoryAi.RemoteInference.ConsentGranted = true; + tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10"; + tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"); + tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com"; + } + + private static string ComputeSha256(string value) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static Dictionary ExtractProperties(BsonDocument document) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!document.TryGetValue("properties", out var propertiesValue)) + { + return result; + } + + foreach (var item in propertiesValue.AsBsonArray) + { + if (item is not BsonDocument property) + { + continue; + } + + var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null; + var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null; + + if (!string.IsNullOrWhiteSpace(name)) + { + result[name] = value ?? string.Empty; + } + } + + return result; + } + + private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.") + { + return new RemoteInferencePayload( + TaskType: "summary", + Profile: profile, + ModelId: "gpt-4o-mini", + Prompt: prompt, + ContextDigest: "sha256:context", + OutputHash: "sha256:output", + TaskId: "task-123", + Metadata: new Dictionary + { + ["channel"] = "cli", + ["env"] = "test" + }); + } + + private sealed record RemoteInferencePayload( + [property: JsonPropertyName("taskType")] string TaskType, + [property: JsonPropertyName("profile")] string Profile, + [property: JsonPropertyName("modelId")] string ModelId, + [property: JsonPropertyName("prompt")] string Prompt, + [property: JsonPropertyName("contextDigest")] string ContextDigest, + [property: JsonPropertyName("outputHash")] string OutputHash, + [property: JsonPropertyName("taskId")] string TaskId, + [property: JsonPropertyName("metadata")] IDictionary Metadata); +} 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 2b0f10e6..64b23512 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs @@ -1,672 +1,672 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Time.Testing; -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using StellaOps.Auth.Abstractions; -using Microsoft.AspNetCore.Routing; -using StellaOps.Configuration; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Authority.Storage.Mongo.Sessions; -using StellaOps.Authority.Tests.Infrastructure; -using StellaOps.Cryptography.Audit; -using Xunit; - -namespace StellaOps.Authority.Tests.Bootstrap; - -public sealed class ServiceAccountAdminEndpointsTests : IClassFixture -{ - private const string BootstrapKey = "test-bootstrap-key"; - private const string TenantId = "tenant-default"; - private const string ServiceAccountId = "svc-observer"; - - private readonly AuthorityWebApplicationFactory factory; - - public ServiceAccountAdminEndpointsTests(AuthorityWebApplicationFactory factory) - { - this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); - } - - [Fact] - public async Task List_ReturnsUnauthorized_WhenBootstrapKeyMissing() - { - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - - var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task List_ReturnsBadRequest_WhenTenantMissing() - { - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.GetAsync("/internal/service-accounts"); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task List_ReturnsServiceAccountsForTenant() - { - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - await using (var scope = app.Services.CreateAsyncScope()) - { - var options = scope.ServiceProvider.GetRequiredService>(); - 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() - .Single(endpoint => - { - var pattern = endpoint.RoutePattern.RawText?.TrimStart('/'); - return string.Equals(pattern, "internal/service-accounts", StringComparison.OrdinalIgnoreCase); - }); - Assert.Equal("internal/service-accounts", serviceAccountsEndpoint.RoutePattern.RawText?.TrimStart('/')); - } - - var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(default); - Assert.NotNull(payload); - - var serviceAccount = Assert.Single(payload!); - Assert.Equal(ServiceAccountId, serviceAccount.AccountId); - Assert.Equal(TenantId, serviceAccount.Tenant); - Assert.Equal("Observability Exporter", serviceAccount.DisplayName); - Assert.True(serviceAccount.Enabled); - Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes); - Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients); - Assert.NotNull(serviceAccount.Attributes); - Assert.True(serviceAccount.Attributes.TryGetValue("env", out var envValues)); - Assert.Equal(new[] { "prod" }, envValues); - Assert.True(serviceAccount.Attributes.TryGetValue("owner", out var ownerValues)); - Assert.Equal(new[] { "vuln-team" }, ownerValues); - Assert.True(serviceAccount.Attributes.TryGetValue("business_tier", out var tierValues)); - Assert.Equal(new[] { "tier-1" }, tierValues); - - await using (var verificationScope = app.Services.CreateAsyncScope()) - { - var accountStore = verificationScope.ServiceProvider.GetRequiredService(); - var document = await accountStore.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); - Assert.NotNull(document); - Assert.True(document!.Enabled); - } - } - - [Fact] - public async Task Tokens_ReturnsActiveDelegationTokens() - { - using var app = CreateApplication(); - - await using (var scope = app.Services.CreateAsyncScope()) - { - var tokenStore = scope.ServiceProvider.GetRequiredService(); - var document = new AuthorityTokenDocument - { - TokenId = "token-1", - ClientId = "export-center-worker", - Status = "valid", - Scope = new List { "jobs:read", "findings:read" }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20), - Tenant = TenantId, - ServiceAccountId = ServiceAccountId, - TokenKind = "service_account" - }; - - await tokenStore.InsertAsync(document, CancellationToken.None); - } - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.GetAsync($"/internal/service-accounts/{ServiceAccountId}/tokens"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(default); - Assert.NotNull(payload); - - var token = Assert.Single(payload!); - Assert.Equal("token-1", token.TokenId); - Assert.Equal("export-center-worker", token.ClientId); - Assert.Equal("valid", token.Status); - Assert.Equal(new[] { "findings:read", "jobs:read" }, token.Scopes); - Assert.Empty(token.Actors); - } - - [Fact] - public async Task Tokens_ReturnsNotFound_WhenServiceAccountMissing() - { - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.GetAsync("/internal/service-accounts/svc-missing/tokens"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Revoke_RevokesAllActiveTokens_AndEmitsAuditEvent() - { - var sink = new RecordingAuthEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T18:00:00Z")); - - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - var tokenIds = new[] { "token-a", "token-b" }; - - await using (var scope = app.Services.CreateAsyncScope()) - { - var tokenStore = scope.ServiceProvider.GetRequiredService(); - - foreach (var tokenId in tokenIds) - { - await tokenStore.InsertAsync(new AuthorityTokenDocument - { - TokenId = tokenId, - ClientId = "export-center-worker", - Status = "valid", - Scope = new List { "jobs:read" }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), - Tenant = TenantId, - ServiceAccountId = ServiceAccountId, - TokenKind = "service_account" - }, CancellationToken.None); - } - } - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new - { - reason = "operator_request", - reasonDescription = "Rotate credentials" - }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(default); - Assert.NotNull(payload); - Assert.Equal(2, payload!.RevokedCount); - Assert.Equal(tokenIds.OrderBy(id => id, StringComparer.Ordinal), payload.TokenIds.OrderBy(id => id, StringComparer.Ordinal)); - - await using (var scope = app.Services.CreateAsyncScope()) - { - var tokenStore = scope.ServiceProvider.GetRequiredService(); - - foreach (var tokenId in tokenIds) - { - var sessionAccessor = scope.ServiceProvider.GetRequiredService(); - 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, evt => evt.EventType == "authority.delegation.revoked"); - Assert.Equal(AuthEventOutcome.Success, audit.Outcome); - Assert.Equal("operator_request", audit.Reason); - Assert.Contains(audit.Properties, property => - string.Equals(property.Name, "delegation.service_account", StringComparison.Ordinal) && - string.Equals(property.Value.Value, ServiceAccountId, StringComparison.Ordinal)); - Assert.Contains(audit.Properties, property => - string.Equals(property.Name, "delegation.revoked_count", StringComparison.Ordinal) && - string.Equals(property.Value.Value, "2", StringComparison.Ordinal)); - } - - [Fact] - public async Task Revoke_ReturnsNotFound_WhenServiceAccountMissing() - { - var sink = new RecordingAuthEventSink(); - - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.PostAsJsonAsync("/internal/service-accounts/svc-unknown/revocations", new { reason = "rotate" }); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Empty(sink.Events); - } - - [Fact] - public async Task Revoke_ReturnsNotFound_WhenTokenNotFound() - { - var sink = new RecordingAuthEventSink(); - - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { tokenId = "missing-token", reason = "cleanup" }); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Empty(sink.Events); - } - - [Fact] - public async Task Revoke_ReturnsFailure_WhenNoActiveTokens() - { - var sink = new RecordingAuthEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z")); - - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - await using (var scope = app.Services.CreateAsyncScope()) - { - var tokenStore = scope.ServiceProvider.GetRequiredService(); - await tokenStore.InsertAsync(new AuthorityTokenDocument - { - TokenId = "token-revoked", - ClientId = "export-center-worker", - Status = "revoked", - Scope = new List { "jobs:read" }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20), - Tenant = TenantId, - ServiceAccountId = ServiceAccountId, - TokenKind = "service_account" - }, CancellationToken.None); - } - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "cleanup" }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(default); - Assert.NotNull(payload); - Assert.Equal(0, payload!.RevokedCount); - Assert.Empty(payload.TokenIds); - - var audit = Assert.Single(sink.Events); - Assert.Equal(AuthEventOutcome.Failure, audit.Outcome); - Assert.Equal("cleanup", audit.Reason); - Assert.Equal("0", GetPropertyValue(audit, "delegation.revoked_count")); - } - - [Fact] - public async Task Revoke_ReturnsSuccess_WhenPartiallyRevokingTokens() - { - var sink = new RecordingAuthEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:30:00Z")); - - using var app = CreateApplication(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - }); - }); - - await using (var scope = app.Services.CreateAsyncScope()) - { - var tokenStore = scope.ServiceProvider.GetRequiredService(); - - await tokenStore.InsertAsync(new AuthorityTokenDocument - { - TokenId = "token-active", - ClientId = "export-center-worker", - Status = "valid", - Scope = new List { "jobs:read" }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), - Tenant = TenantId, - ServiceAccountId = ServiceAccountId, - TokenKind = "service_account" - }, CancellationToken.None); - - await tokenStore.InsertAsync(new AuthorityTokenDocument - { - TokenId = "token-already-revoked", - ClientId = "export-center-worker", - Status = "revoked", - Scope = new List { "jobs:read" }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-25), - Tenant = TenantId, - ServiceAccountId = ServiceAccountId, - TokenKind = "service_account" - }, CancellationToken.None); - } - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); - - var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "partial" }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(default); - Assert.NotNull(payload); - Assert.Equal(1, payload!.RevokedCount); - Assert.Equal(new[] { "token-active" }, payload.TokenIds); - - var audit = Assert.Single(sink.Events); - Assert.Equal(AuthEventOutcome.Success, audit.Outcome); - Assert.Equal("partial", audit.Reason); - Assert.Equal("1", GetPropertyValue(audit, "delegation.revoked_count")); - Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]")); - } - - [Fact] - public async Task Bootstrap_RepeatedSeeding_PreservesServiceAccountIdentity() - { - string? initialId; - DateTimeOffset initialCreatedAt; - - using (var firstApp = CreateApplication()) - { - await using var scope = firstApp.Services.CreateAsyncScope(); - var store = scope.ServiceProvider.GetRequiredService(); - var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); - - Assert.NotNull(document); - initialId = document!.Id; - initialCreatedAt = document.CreatedAt; - } - - using (var secondApp = CreateApplication()) - { - await using var scope = secondApp.Services.CreateAsyncScope(); - var store = scope.ServiceProvider.GetRequiredService(); - var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); - - Assert.NotNull(document); - Assert.Equal(initialId, document!.Id); - Assert.Equal(initialCreatedAt, document.CreatedAt); - Assert.True(document.UpdatedAt >= initialCreatedAt); - } - } - - private WebApplicationFactory CreateApplication(Action? configure = null) - { - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED", "true"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY", BootstrapKey); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER", "standard"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID", TenantId); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME", "Default Tenant"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS", "50"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID", ServiceAccountId); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT", TenantId); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ENABLED", "true"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME", "Observability Exporter"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION", "Automates evidence exports."); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0", "jobs:read"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1", "findings:read"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0", "export-center-worker"); - - return factory.WithWebHostBuilder(host => - { - host.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Bootstrap:Enabled"] = "true", - ["Authority:Bootstrap:ApiKey"] = BootstrapKey, - ["Authority:Bootstrap:DefaultIdentityProvider"] = "standard", - ["Authority:Tenants:0:Id"] = TenantId, - ["Authority:Tenants:0:DisplayName"] = "Default Tenant", - ["Authority:Delegation:Quotas:MaxActiveTokens"] = "50", - ["Authority:Delegation:ServiceAccounts:0:AccountId"] = ServiceAccountId, - ["Authority:Delegation:ServiceAccounts:0:Tenant"] = TenantId, - ["Authority:Delegation:ServiceAccounts:0:DisplayName"] = "Observability Exporter", - ["Authority:Delegation:ServiceAccounts:0:Description"] = "Automates evidence exports.", - ["Authority:Delegation:ServiceAccounts:0:AllowedScopes:0"] = "jobs:read", - ["Authority:Delegation:ServiceAccounts:0:AllowedScopes:1"] = "findings:read", - ["Authority:Delegation:ServiceAccounts:0:AuthorizedClients:0"] = "export-center-worker" - }); - }); - - host.ConfigureServices(services => - { - services.PostConfigure(options => - { - options.Bootstrap.Enabled = true; - options.Bootstrap.ApiKey = BootstrapKey; - options.Bootstrap.DefaultIdentityProvider = "standard"; - - if (options.Tenants.Count == 0) - { - options.Tenants.Add(new AuthorityTenantOptions - { - Id = TenantId, - DisplayName = "Default Tenant" - }); - } - - options.Delegation.Quotas.MaxActiveTokens = 50; - - var serviceAccount = options.Delegation.ServiceAccounts - .FirstOrDefault(account => string.Equals(account.AccountId, ServiceAccountId, StringComparison.OrdinalIgnoreCase)); - - if (serviceAccount is null) - { - 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" }; - }); - }); - - configure?.Invoke(host); - }); - } - - private static string? GetPropertyValue(AuthEventRecord record, string name) - { - return record.Properties - .FirstOrDefault(property => string.Equals(property.Name, name, StringComparison.Ordinal)) - ?.Value.Value; - } - - private sealed record ServiceAccountResponse( - string AccountId, - string Tenant, - string? DisplayName, - string? Description, - bool Enabled, - IReadOnlyList AllowedScopes, - IReadOnlyList AuthorizedClients, - IReadOnlyDictionary> Attributes); - - private sealed record ServiceAccountTokenResponse( - string TokenId, - string? ClientId, - string Status, - IReadOnlyList Scopes, - IReadOnlyList Actors); - - private sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList TokenIds); - - private sealed class RecordingAuthEventSink : IAuthEventSink - { - private readonly List events = new(); - - public IReadOnlyList Events => events; - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - lock (events) - { - events.Add(record); - } - - return ValueTask.CompletedTask; - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using StellaOps.Auth.Abstractions; +using Microsoft.AspNetCore.Routing; +using StellaOps.Configuration; +using StellaOps.Authority.OpenIddict; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Cryptography.Audit; +using Xunit; + +namespace StellaOps.Authority.Tests.Bootstrap; + +public sealed class ServiceAccountAdminEndpointsTests : IClassFixture +{ + private const string BootstrapKey = "test-bootstrap-key"; + private const string TenantId = "tenant-default"; + private const string ServiceAccountId = "svc-observer"; + + private readonly AuthorityWebApplicationFactory factory; + + public ServiceAccountAdminEndpointsTests(AuthorityWebApplicationFactory factory) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + [Fact] + public async Task List_ReturnsUnauthorized_WhenBootstrapKeyMissing() + { + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + + var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task List_ReturnsBadRequest_WhenTenantMissing() + { + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.GetAsync("/internal/service-accounts"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task List_ReturnsServiceAccountsForTenant() + { + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var options = scope.ServiceProvider.GetRequiredService>(); + 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() + .Single(endpoint => + { + var pattern = endpoint.RoutePattern.RawText?.TrimStart('/'); + return string.Equals(pattern, "internal/service-accounts", StringComparison.OrdinalIgnoreCase); + }); + Assert.Equal("internal/service-accounts", serviceAccountsEndpoint.RoutePattern.RawText?.TrimStart('/')); + } + + var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(default); + Assert.NotNull(payload); + + var serviceAccount = Assert.Single(payload!); + Assert.Equal(ServiceAccountId, serviceAccount.AccountId); + Assert.Equal(TenantId, serviceAccount.Tenant); + Assert.Equal("Observability Exporter", serviceAccount.DisplayName); + Assert.True(serviceAccount.Enabled); + Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes); + Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients); + Assert.NotNull(serviceAccount.Attributes); + Assert.True(serviceAccount.Attributes.TryGetValue("env", out var envValues)); + Assert.Equal(new[] { "prod" }, envValues); + Assert.True(serviceAccount.Attributes.TryGetValue("owner", out var ownerValues)); + Assert.Equal(new[] { "vuln-team" }, ownerValues); + Assert.True(serviceAccount.Attributes.TryGetValue("business_tier", out var tierValues)); + Assert.Equal(new[] { "tier-1" }, tierValues); + + await using (var verificationScope = app.Services.CreateAsyncScope()) + { + var accountStore = verificationScope.ServiceProvider.GetRequiredService(); + var document = await accountStore.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); + Assert.NotNull(document); + Assert.True(document!.Enabled); + } + } + + [Fact] + public async Task Tokens_ReturnsActiveDelegationTokens() + { + using var app = CreateApplication(); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var tokenStore = scope.ServiceProvider.GetRequiredService(); + var document = new AuthorityTokenDocument + { + TokenId = "token-1", + ClientId = "export-center-worker", + Status = "valid", + Scope = new List { "jobs:read", "findings:read" }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20), + Tenant = TenantId, + ServiceAccountId = ServiceAccountId, + TokenKind = "service_account" + }; + + await tokenStore.InsertAsync(document, CancellationToken.None); + } + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.GetAsync($"/internal/service-accounts/{ServiceAccountId}/tokens"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(default); + Assert.NotNull(payload); + + var token = Assert.Single(payload!); + Assert.Equal("token-1", token.TokenId); + Assert.Equal("export-center-worker", token.ClientId); + Assert.Equal("valid", token.Status); + Assert.Equal(new[] { "findings:read", "jobs:read" }, token.Scopes); + Assert.Empty(token.Actors); + } + + [Fact] + public async Task Tokens_ReturnsNotFound_WhenServiceAccountMissing() + { + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.GetAsync("/internal/service-accounts/svc-missing/tokens"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Revoke_RevokesAllActiveTokens_AndEmitsAuditEvent() + { + var sink = new RecordingAuthEventSink(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T18:00:00Z")); + + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + var tokenIds = new[] { "token-a", "token-b" }; + + await using (var scope = app.Services.CreateAsyncScope()) + { + var tokenStore = scope.ServiceProvider.GetRequiredService(); + + foreach (var tokenId in tokenIds) + { + await tokenStore.InsertAsync(new AuthorityTokenDocument + { + TokenId = tokenId, + ClientId = "export-center-worker", + Status = "valid", + Scope = new List { "jobs:read" }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), + Tenant = TenantId, + ServiceAccountId = ServiceAccountId, + TokenKind = "service_account" + }, CancellationToken.None); + } + } + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new + { + reason = "operator_request", + reasonDescription = "Rotate credentials" + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(default); + Assert.NotNull(payload); + Assert.Equal(2, payload!.RevokedCount); + Assert.Equal(tokenIds.OrderBy(id => id, StringComparer.Ordinal), payload.TokenIds.OrderBy(id => id, StringComparer.Ordinal)); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var tokenStore = scope.ServiceProvider.GetRequiredService(); + + foreach (var tokenId in tokenIds) + { + var sessionAccessor = scope.ServiceProvider.GetRequiredService(); + 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, evt => evt.EventType == "authority.delegation.revoked"); + Assert.Equal(AuthEventOutcome.Success, audit.Outcome); + Assert.Equal("operator_request", audit.Reason); + Assert.Contains(audit.Properties, property => + string.Equals(property.Name, "delegation.service_account", StringComparison.Ordinal) && + string.Equals(property.Value.Value, ServiceAccountId, StringComparison.Ordinal)); + Assert.Contains(audit.Properties, property => + string.Equals(property.Name, "delegation.revoked_count", StringComparison.Ordinal) && + string.Equals(property.Value.Value, "2", StringComparison.Ordinal)); + } + + [Fact] + public async Task Revoke_ReturnsNotFound_WhenServiceAccountMissing() + { + var sink = new RecordingAuthEventSink(); + + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.PostAsJsonAsync("/internal/service-accounts/svc-unknown/revocations", new { reason = "rotate" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(sink.Events); + } + + [Fact] + public async Task Revoke_ReturnsNotFound_WhenTokenNotFound() + { + var sink = new RecordingAuthEventSink(); + + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { tokenId = "missing-token", reason = "cleanup" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(sink.Events); + } + + [Fact] + public async Task Revoke_ReturnsFailure_WhenNoActiveTokens() + { + var sink = new RecordingAuthEventSink(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z")); + + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var tokenStore = scope.ServiceProvider.GetRequiredService(); + await tokenStore.InsertAsync(new AuthorityTokenDocument + { + TokenId = "token-revoked", + ClientId = "export-center-worker", + Status = "revoked", + Scope = new List { "jobs:read" }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20), + Tenant = TenantId, + ServiceAccountId = ServiceAccountId, + TokenKind = "service_account" + }, CancellationToken.None); + } + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "cleanup" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(default); + Assert.NotNull(payload); + Assert.Equal(0, payload!.RevokedCount); + Assert.Empty(payload.TokenIds); + + var audit = Assert.Single(sink.Events); + Assert.Equal(AuthEventOutcome.Failure, audit.Outcome); + Assert.Equal("cleanup", audit.Reason); + Assert.Equal("0", GetPropertyValue(audit, "delegation.revoked_count")); + } + + [Fact] + public async Task Revoke_ReturnsSuccess_WhenPartiallyRevokingTokens() + { + var sink = new RecordingAuthEventSink(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:30:00Z")); + + using var app = CreateApplication(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + }); + }); + + await using (var scope = app.Services.CreateAsyncScope()) + { + var tokenStore = scope.ServiceProvider.GetRequiredService(); + + await tokenStore.InsertAsync(new AuthorityTokenDocument + { + TokenId = "token-active", + ClientId = "export-center-worker", + Status = "valid", + Scope = new List { "jobs:read" }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), + Tenant = TenantId, + ServiceAccountId = ServiceAccountId, + TokenKind = "service_account" + }, CancellationToken.None); + + await tokenStore.InsertAsync(new AuthorityTokenDocument + { + TokenId = "token-already-revoked", + ClientId = "export-center-worker", + Status = "revoked", + Scope = new List { "jobs:read" }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-25), + Tenant = TenantId, + ServiceAccountId = ServiceAccountId, + TokenKind = "service_account" + }, CancellationToken.None); + } + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey); + + var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "partial" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(default); + Assert.NotNull(payload); + Assert.Equal(1, payload!.RevokedCount); + Assert.Equal(new[] { "token-active" }, payload.TokenIds); + + var audit = Assert.Single(sink.Events); + Assert.Equal(AuthEventOutcome.Success, audit.Outcome); + Assert.Equal("partial", audit.Reason); + Assert.Equal("1", GetPropertyValue(audit, "delegation.revoked_count")); + Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]")); + } + + [Fact] + public async Task Bootstrap_RepeatedSeeding_PreservesServiceAccountIdentity() + { + string? initialId; + DateTimeOffset initialCreatedAt; + + using (var firstApp = CreateApplication()) + { + await using var scope = firstApp.Services.CreateAsyncScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); + + Assert.NotNull(document); + initialId = document!.Id; + initialCreatedAt = document.CreatedAt; + } + + using (var secondApp = CreateApplication()) + { + await using var scope = secondApp.Services.CreateAsyncScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None); + + Assert.NotNull(document); + Assert.Equal(initialId, document!.Id); + Assert.Equal(initialCreatedAt, document.CreatedAt); + Assert.True(document.UpdatedAt >= initialCreatedAt); + } + } + + private WebApplicationFactory CreateApplication(Action? configure = null) + { + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED", "true"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY", BootstrapKey); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER", "standard"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID", TenantId); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME", "Default Tenant"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS", "50"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID", ServiceAccountId); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT", TenantId); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ENABLED", "true"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME", "Observability Exporter"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION", "Automates evidence exports."); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0", "jobs:read"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1", "findings:read"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0", "export-center-worker"); + + return factory.WithWebHostBuilder(host => + { + host.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Bootstrap:Enabled"] = "true", + ["Authority:Bootstrap:ApiKey"] = BootstrapKey, + ["Authority:Bootstrap:DefaultIdentityProvider"] = "standard", + ["Authority:Tenants:0:Id"] = TenantId, + ["Authority:Tenants:0:DisplayName"] = "Default Tenant", + ["Authority:Delegation:Quotas:MaxActiveTokens"] = "50", + ["Authority:Delegation:ServiceAccounts:0:AccountId"] = ServiceAccountId, + ["Authority:Delegation:ServiceAccounts:0:Tenant"] = TenantId, + ["Authority:Delegation:ServiceAccounts:0:DisplayName"] = "Observability Exporter", + ["Authority:Delegation:ServiceAccounts:0:Description"] = "Automates evidence exports.", + ["Authority:Delegation:ServiceAccounts:0:AllowedScopes:0"] = "jobs:read", + ["Authority:Delegation:ServiceAccounts:0:AllowedScopes:1"] = "findings:read", + ["Authority:Delegation:ServiceAccounts:0:AuthorizedClients:0"] = "export-center-worker" + }); + }); + + host.ConfigureServices(services => + { + services.PostConfigure(options => + { + options.Bootstrap.Enabled = true; + options.Bootstrap.ApiKey = BootstrapKey; + options.Bootstrap.DefaultIdentityProvider = "standard"; + + if (options.Tenants.Count == 0) + { + options.Tenants.Add(new AuthorityTenantOptions + { + Id = TenantId, + DisplayName = "Default Tenant" + }); + } + + options.Delegation.Quotas.MaxActiveTokens = 50; + + var serviceAccount = options.Delegation.ServiceAccounts + .FirstOrDefault(account => string.Equals(account.AccountId, ServiceAccountId, StringComparison.OrdinalIgnoreCase)); + + if (serviceAccount is null) + { + 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" }; + }); + }); + + configure?.Invoke(host); + }); + } + + private static string? GetPropertyValue(AuthEventRecord record, string name) + { + return record.Properties + .FirstOrDefault(property => string.Equals(property.Name, name, StringComparison.Ordinal)) + ?.Value.Value; + } + + private sealed record ServiceAccountResponse( + string AccountId, + string Tenant, + string? DisplayName, + string? Description, + bool Enabled, + IReadOnlyList AllowedScopes, + IReadOnlyList AuthorizedClients, + IReadOnlyDictionary> Attributes); + + private sealed record ServiceAccountTokenResponse( + string TokenId, + string? ClientId, + string Status, + IReadOnlyList Scopes, + IReadOnlyList Actors); + + private sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList TokenIds); + + private sealed class RecordingAuthEventSink : IAuthEventSink + { + private readonly List events = new(); + + public IReadOnlyList Events => events; + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + lock (events) + { + events.Add(record); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs index 739cc6c2..d9e64405 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/EnvironmentVariableScope.cs @@ -1,44 +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; - } -} +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 b0b6785e..5a55ad21 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAuthHandler.cs @@ -1,57 +1,57 @@ -using System; -using System.Collections.Generic; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.Auth.Abstractions; - -namespace StellaOps.Authority.Tests.Infrastructure; - -internal sealed class TestAuthHandler : AuthenticationHandler -{ - public const string SchemeName = "TestAuth"; - - public TestAuthHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) - { - } - - protected override Task HandleAuthenticateAsync() - { - var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues) - ? tenantValues.ToString() - : "tenant-default"; - - var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues) - ? scopeValues.ToString() - : StellaOpsScopes.AdvisoryAiOperate; - - var claims = new List - { - new Claim(StellaOpsClaimTypes.ClientId, "test-client") - }; - - if (!string.IsNullOrWhiteSpace(tenantHeader) && - !string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase)) - { - claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim())); - } - - var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var scope in scopes) - { - claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope)); - } - - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(ticket)); - } -} +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Auth.Abstractions; + +namespace StellaOps.Authority.Tests.Infrastructure; + +internal sealed class TestAuthHandler : AuthenticationHandler +{ + public const string SchemeName = "TestAuth"; + + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues) + ? tenantValues.ToString() + : "tenant-default"; + + var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues) + ? scopeValues.ToString() + : StellaOpsScopes.AdvisoryAiOperate; + + var claims = new List + { + new Claim(StellaOpsClaimTypes.ClientId, "test-client") + }; + + if (!string.IsNullOrWhiteSpace(tenantHeader) && + !string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase)) + { + claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim())); + } + + var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var scope in scopes) + { + claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope)); + } + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} 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 8d5ed27a..d8eba239 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/NotifyAckTokenRotationEndpointTests.cs @@ -1,259 +1,259 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Time.Testing; -using StellaOps.Auth.Abstractions; -using StellaOps.Authority; -using StellaOps.Authority.Tests.Infrastructure; -using StellaOps.Cryptography; -using StellaOps.Cryptography.Audit; -using StellaOps.Configuration; -using Xunit; - -namespace StellaOps.Authority.Tests.Notifications; - -public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture -{ - private readonly AuthorityWebApplicationFactory factory; - - public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory) - { - this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); - } - - [Fact] - public async Task Rotate_ReturnsOk_AndEmitsAuditEvent() - { - const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED"; - const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID"; - const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH"; - const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE"; - const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM"; - const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED"; - const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0"; - - var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success"); - try - { - var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem"); - var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem"); - CreateEcPrivateKey(key1Path); - CreateEcPrivateKey(key2Path); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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 scopedFactory = factory.WithWebHostBuilder(host => - { - host.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Notifications:AckTokens:Enabled"] = "true", - ["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1", - ["Authority:Notifications:AckTokens:KeyPath"] = key1Path, - ["Authority:Notifications:AckTokens:KeySource"] = "file", - ["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256, - ["Authority:Notifications:Webhooks:Enabled"] = "true", - ["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com", - ["Authority:Notifications:Escalation:Scope"] = "notify.escalate", - ["Authority:Notifications:Escalation:RequireAdminScope"] = "true" - }); - }); - - host.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - services.PostConfigure(options => - { - options.Notifications.AckTokens.Enabled = true; - options.Notifications.AckTokens.ActiveKeyId = "ack-key-1"; - options.Notifications.AckTokens.KeyPath = key1Path; - options.Notifications.AckTokens.KeySource = "file"; - options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256; - }); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); - }); - }); - - using var client = scopedFactory.CreateClient(); - - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new - { - keyId = "ack-key-2", - location = key2Path - }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.Equal("ack-key-2", payload!.ActiveKeyId); - Assert.Equal("ack-key-1", payload.PreviousKeyId); - - 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) && - string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal)); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - [Fact] - public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure() - { - var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure"); - try - { - var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem"); - var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem"); - CreateEcPrivateKey(key1Path); - CreateEcPrivateKey(key2Path); - - var sink = new RecordingAuthEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z")); - - using var app = factory.WithWebHostBuilder(host => - { - host.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Notifications:AckTokens:Enabled"] = "true", - ["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1", - ["Authority:Notifications:AckTokens:KeyPath"] = key1Path, - ["Authority:Notifications:AckTokens:KeySource"] = "file", - ["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256, - ["Authority:Notifications:Webhooks:Enabled"] = "true", - ["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com" - }); - }); - - host.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - services.PostConfigure(options => - { - options.Notifications.AckTokens.Enabled = true; - options.Notifications.AckTokens.ActiveKeyId = "ack-key-1"; - options.Notifications.AckTokens.KeyPath = key1Path; - options.Notifications.AckTokens.KeySource = "file"; - options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256; - }); - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); - }); - }); - - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new - { - location = key2Path - }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - 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); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - private static void CreateEcPrivateKey(string path) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem()); - } - - private static void TryDeleteDirectory(string path) - { - try - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - catch - { - // Ignore cleanup failures in tests. - } - } - - private sealed record AckRotateResponse( - string ActiveKeyId, - string? Provider, - string? Source, - string? Location, - string? PreviousKeyId, - IReadOnlyCollection RetiredKeyIds); - - private sealed class RecordingAuthEventSink : IAuthEventSink - { - private readonly ConcurrentQueue events = new(); - - public IReadOnlyCollection Events => events.ToArray(); - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - events.Enqueue(record); - return ValueTask.CompletedTask; - } - } -} +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Auth.Abstractions; +using StellaOps.Authority; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Cryptography; +using StellaOps.Cryptography.Audit; +using StellaOps.Configuration; +using Xunit; + +namespace StellaOps.Authority.Tests.Notifications; + +public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture +{ + private readonly AuthorityWebApplicationFactory factory; + + public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + [Fact] + public async Task Rotate_ReturnsOk_AndEmitsAuditEvent() + { + const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED"; + const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID"; + const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH"; + const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE"; + const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM"; + const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED"; + const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0"; + + var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success"); + try + { + var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem"); + var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem"); + CreateEcPrivateKey(key1Path); + CreateEcPrivateKey(key2Path); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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 scopedFactory = factory.WithWebHostBuilder(host => + { + host.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Notifications:AckTokens:Enabled"] = "true", + ["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1", + ["Authority:Notifications:AckTokens:KeyPath"] = key1Path, + ["Authority:Notifications:AckTokens:KeySource"] = "file", + ["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256, + ["Authority:Notifications:Webhooks:Enabled"] = "true", + ["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com", + ["Authority:Notifications:Escalation:Scope"] = "notify.escalate", + ["Authority:Notifications:Escalation:RequireAdminScope"] = "true" + }); + }); + + host.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + services.PostConfigure(options => + { + options.Notifications.AckTokens.Enabled = true; + options.Notifications.AckTokens.ActiveKeyId = "ack-key-1"; + options.Notifications.AckTokens.KeyPath = key1Path; + options.Notifications.AckTokens.KeySource = "file"; + options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256; + }); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); + }); + }); + + using var client = scopedFactory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new + { + keyId = "ack-key-2", + location = key2Path + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal("ack-key-2", payload!.ActiveKeyId); + Assert.Equal("ack-key-1", payload.PreviousKeyId); + + 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) && + string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal)); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + [Fact] + public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure() + { + var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure"); + try + { + var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem"); + var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem"); + CreateEcPrivateKey(key1Path); + CreateEcPrivateKey(key2Path); + + var sink = new RecordingAuthEventSink(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z")); + + using var app = factory.WithWebHostBuilder(host => + { + host.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Notifications:AckTokens:Enabled"] = "true", + ["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1", + ["Authority:Notifications:AckTokens:KeyPath"] = key1Path, + ["Authority:Notifications:AckTokens:KeySource"] = "file", + ["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256, + ["Authority:Notifications:Webhooks:Enabled"] = "true", + ["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com" + }); + }); + + host.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + services.PostConfigure(options => + { + options.Notifications.AckTokens.Enabled = true; + options.Notifications.AckTokens.ActiveKeyId = "ack-key-1"; + options.Notifications.AckTokens.KeyPath = key1Path; + options.Notifications.AckTokens.KeySource = "file"; + options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256; + }); + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); + }); + }); + + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new + { + location = key2Path + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + 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); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + private static void CreateEcPrivateKey(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem()); + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Ignore cleanup failures in tests. + } + } + + private sealed record AckRotateResponse( + string ActiveKeyId, + string? Provider, + string? Source, + string? Location, + string? PreviousKeyId, + IReadOnlyCollection RetiredKeyIds); + + private sealed class RecordingAuthEventSink : IAuthEventSink + { + private readonly ConcurrentQueue events = new(); + + public IReadOnlyCollection Events => events.ToArray(); + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + events.Enqueue(record); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs index ba51c948..b5e59a1f 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs @@ -1,2904 +1,2904 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.Tokens; -using StellaOps.Configuration; -using OpenIddict.Abstractions; -using StellaOps.Authority.Security; -using StellaOps.Auth.Security.Dpop; -using OpenIddict.Extensions; -using OpenIddict.Server; -using OpenIddict.Server.AspNetCore; -using StellaOps.Auth.Abstractions; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.OpenIddict.Handlers; -using StellaOps.Authority.Plugins.Abstractions; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Sessions; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Authority.RateLimiting; -using StellaOps.Cryptography.Audit; -using Xunit; -using MongoDB.Bson; -using MongoDB.Driver; -using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; - -namespace StellaOps.Authority.Tests.OpenIddict; - -public class ClientCredentialsHandlersTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'jobs:write' is not allowed for this client.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsObsIncidentScope() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "obs:incident"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "obs:incident"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Contains("obs:incident", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_Allows_WhenConfigurationMatches() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read jobs:trigger"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty)); - Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]); - - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "jobs:read" }, grantedScopes); - Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_Allows_NewIngestionScopes() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:ingest" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-observer", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var tokenStore = new TestTokenStore(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj)); - var resolvedAccount = Assert.IsType(serviceAccountObj); - Assert.Equal("svc-observer", resolvedAccount.AccountId); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Contains("jobs:read", grantedScopes); - Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId); - Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 1; - }); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-observer", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "existing-token", - Status = "valid", - Tenant = "tenant-alpha", - ClientId = clientDocument.ClientId, - ServiceAccountId = "svc-observer", - TokenKind = AuthorityTokenKinds.ServiceAccount, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), - Scope = new List { "jobs:read" } - } - }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - new TestServiceAccountStore(serviceAccount), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - 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); - - Assert.False(context.IsRejected); - Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]); - Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]); - Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]); - - var metadata = metadataAccessor.GetMetadata(); - Assert.NotNull(metadata); - Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag)); - Assert.Equal("prod", envTag); - } - - [Fact] - public async Task HandleClientCredentials_PersistsVulnAttributes() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var tokenStore = new TestTokenStore(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - transaction.Options = new OpenIddictServerOptions - { - AccessTokenLifetime = TimeSpan.FromMinutes(5) - }; - 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); - Assert.False(validateContext.IsRejected); - - var sessionAccessor = new NullMongoSessionAccessor(); - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - - Assert.True(handleContext.IsRequestHandled); - var principal = Assert.IsType(handleContext.Principal); - Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment)); - Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner)); - Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier)); - - var persistHandler = new PersistTokensHandler( - tokenStore, - sessionAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = principal, - AccessTokenPrincipal = principal - }; - - await persistHandler.HandleAsync(signInContext); - - Assert.NotNull(tokenStore.Inserted); - Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment); - Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner); - Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:read aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "signals:read signals:write signals:admin aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Theory] - [InlineData(StellaOpsScopes.AirgapSeal)] - [InlineData(StellaOpsScopes.AirgapImport)] - [InlineData(StellaOpsScopes.AirgapStatusRead)] - public async Task ValidateClientCredentials_RejectsAirgapScopesWithoutTenant(string scope) - { - var clientId = $"airgap-{scope.Replace(':', '-')}-client"; - var clientDocument = CreateClient( - clientId: clientId, - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: scope); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Air-gap scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(scope, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Theory] - [InlineData(StellaOpsScopes.AirgapSeal)] - [InlineData(StellaOpsScopes.AirgapImport)] - [InlineData(StellaOpsScopes.AirgapStatusRead)] - public async Task ValidateClientCredentials_AllowsAirgapScopesWithTenant(string scope) - { - var clientId = $"airgap-{scope.Replace(':', '-')}-client"; - var clientDocument = CreateClient( - clientId: clientId, - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: scope, - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { scope }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:author"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:author", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "policy:author" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:publish", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:promote", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:read aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "aoc:verify"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant)); - Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "effective:write" }, grantedScopes); - - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill actions require 'backfill_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill actions require 'backfill_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longReason = new string('a', 257); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill reason must not exceed 256 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longTicket = new string('b', 129); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill ticket must not exceed 128 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_PopulatesBackfillMetadata_OnSuccess() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:backfill" }, grantedScopes); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty]); - Assert.Equal("Backfill drift repair", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty]); - Assert.Equal("INC-9981", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:operate" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); - Assert.Equal("resume source after maintenance", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); - Assert.Equal("INC-2045", ticket); - Assert.Equal("resume source after maintenance", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); - Assert.Equal("INC-2045", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - 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); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota changes require 'quota_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longReason = new string('a', 257); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota reason must not exceed 256 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTicketTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - 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); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota ticket must not exceed 128 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonOnly() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:quota" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); - Assert.Equal("grant five extra concurrent backfills", reason); - Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.QuotaTicketProperty)); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - 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); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:quota" }, grantedScopes); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); - Assert.Equal("temporary burst for export audit", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaTicketProperty]); - Assert.Equal("RFC-5541", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "export-viewer", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.viewer"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant() - { - var clientDocument = CreateClient( - clientId: "export-viewer", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.viewer", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "export.viewer" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPacksRun_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "task-runner", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "packs.run packs.read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - string? violationTag = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == TestActivitySource.Name, - Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => - { - violationTag ??= activity.GetTagItem("authority.pack_scope_violation") as string; - } - }; - ActivitySource.AddActivityListener(listener); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Pack scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PacksRun, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Equal(StellaOpsScopes.PacksRun, violationTag); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsPacksRun_WithTenant() - { - var clientDocument = CreateClient( - clientId: "task-runner", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "packs.run packs.read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "packs.run" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - 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); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "export.admin" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]); - Assert.Equal("Rotate encryption keys after incident postmortem", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]); - Assert.Equal("INC-9001", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "graph-api", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:read graph:export"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant() - { - var clientDocument = CreateClient( - clientId: "graph-api", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:read graph:export", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "graph:read" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-dashboard", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant() - { - var clientDocument = CreateClient( - clientId: "orch-dashboard", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:read" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "concelier-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "excitor-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vex:ingest vex:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "vuln-explorer-ui", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view vuln:investigate"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant() - { - var clientDocument = CreateClient( - clientId: "concelier-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read aoc:verify", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "graph:write" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var sink = new TestAuthEventSink(); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - sink, - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction,"unexpected_param", "value"); - - await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - - var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); - Assert.Contains(tamperEvent.Properties, property => - string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && - string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task ValidateDpopProof_AllowsSenderConstrainedClient() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Dpop.Enabled = true; - opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var securityKey = new ECDsaSecurityKey(ecdsa) - { - KeyId = Guid.NewGuid().ToString("N") - }; - var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); - var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); - - var clientStore = new TestClientStore(clientDocument); - var auditSink = new TestAuthEventSink(); - var rateMetadata = new TestRateLimiterMetadataAccessor(); - - var dpopValidator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var dpopHandler = new ValidateDpopProofHandler( - options, - clientStore, - dpopValidator, - nonceStore, - rateMetadata, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - - var now = TimeProvider.System.GetUtcNow(); - var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); - httpContext.Request.Headers["DPoP"] = proof; - - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await dpopHandler.HandleAsync(validateContext); - - Assert.False(validateContext.IsRejected); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var serviceAccountStore = new TestServiceAccountStore(); - var validateHandler = new ValidateClientCredentialsHandler( - clientStore, - registry, - TestActivitySource, - auditSink, - rateMetadata, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var sessionAccessor = new NullMongoSessionAccessor(); - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - rateMetadata, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - Assert.True(handleContext.IsRequestHandled); - - var persistHandler = new PersistTokensHandler( - tokenStore, - sessionAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = handleContext.Principal, - AccessTokenPrincipal = handleContext.Principal - }; - - await persistHandler.HandleAsync(signInContext); - - var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); - Assert.False(string.IsNullOrWhiteSpace(confirmationClaim)); - - using (var confirmationJson = JsonDocument.Parse(confirmationClaim!)) - { - Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); - } - - Assert.NotNull(tokenStore.Inserted); - Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint); - Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint); - } - - [Fact] - public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Dpop.Enabled = true; - options.Security.SenderConstraints.Dpop.Nonce.Enabled = true; - options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); - options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var securityKey = new ECDsaSecurityKey(ecdsa) - { - KeyId = Guid.NewGuid().ToString("N") - }; - - var clientStore = new TestClientStore(clientDocument); - var auditSink = new TestAuthEventSink(); - var rateMetadata = new TestRateLimiterMetadataAccessor(); - - var dpopValidator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var dpopHandler = new ValidateDpopProofHandler( - options, - clientStore, - dpopValidator, - nonceStore, - rateMetadata, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - - var now = TimeProvider.System.GetUtcNow(); - var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); - httpContext.Request.Headers["DPoP"] = proof; - - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await dpopHandler.HandleAsync(validateContext); - - Assert.True(validateContext.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); - 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)); - Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge"); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; - - using var rsa = RSA.Create(2048); - var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); - var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = hexThumbprint - }); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var auditSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(); - var tokenStore = new TestTokenStore(); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - auditSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - validator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, context.ErrorDescription ?? context.Error); - Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]); - - var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256)); - Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - validator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - } - - [Fact] - public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Mtls.Enabled = true; - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Mtls.Enabled = true; - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = "DEADBEEF" - }); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var certificateValidator = new RecordingCertificateValidator(); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - certificateValidator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("client_certificate_required", context.ErrorDescription); - Assert.True(certificateValidator.Invoked); - } - - [Fact] - public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims() - { - var clientDocument = CreateClient( - secret: null, - clientType: "public", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:trigger", - allowedAudiences: "signer", - tenant: "Tenant-Alpha"); - - var descriptor = CreateDescriptor(clientDocument); - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(); - var options = TestHelpers.CreateAuthorityOptions(); - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); - transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); - - var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRequestHandled); - Assert.NotNull(context.Principal); - Assert.Contains("signer", context.Principal!.GetAudiences()); - - Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success); - - var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider); - Assert.Equal(clientDocument.Plugin, identityProviderClaim); - - var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); - Assert.False(string.IsNullOrWhiteSpace(tokenId)); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = principal, - AccessTokenPrincipal = principal - }; - - await persistHandler.HandleAsync(signInContext); - - var persisted = Assert.IsType(tokenStore.Inserted); - Assert.Equal(tokenId, persisted.TokenId); - Assert.Equal(clientDocument.ClientId, persisted.ClientId); - Assert.Equal("valid", persisted.Status); - Assert.Equal("tenant-alpha", persisted.Tenant); - Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope); - } - - [Fact] - public async Task HandleClientCredentials_PersistsServiceAccountMetadata() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-ops", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - Assert.True(handleContext.IsRequestHandled); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = handleContext.Principal, - AccessTokenPrincipal = handleContext.Principal - }; - - await persistHandler.HandleAsync(signInContext); - - var inserted = tokenStore.Inserted; - Assert.NotNull(inserted); - Assert.Equal("svc-ops", inserted!.ServiceAccountId); - Assert.Equal("service_account", inserted.TokenKind); - Assert.NotNull(inserted.ActorChain); - Assert.Contains(clientDocument.ClientId, inserted.ActorChain!); - Assert.Equal("tenant-alpha", inserted.Tenant); - Assert.Contains("jobs:read", inserted.Scope); +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using StellaOps.Configuration; +using OpenIddict.Abstractions; +using StellaOps.Authority.Security; +using StellaOps.Auth.Security.Dpop; +using OpenIddict.Extensions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using StellaOps.Auth.Abstractions; +using StellaOps.Authority.OpenIddict; +using StellaOps.Authority.OpenIddict.Handlers; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.RateLimiting; +using StellaOps.Cryptography.Audit; +using Xunit; +using MongoDB.Bson; +using MongoDB.Driver; +using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public class ClientCredentialsHandlersTests +{ + private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); + + [Fact] + public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'jobs:write' is not allowed for this client.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsObsIncidentScope() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "obs:incident"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "obs:incident"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Contains("obs:incident", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_Allows_WhenConfigurationMatches() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read jobs:trigger"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty)); + Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]); + + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "jobs:read" }, grantedScopes); + Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_Allows_NewIngestionScopes() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:ingest" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-observer", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var tokenStore = new TestTokenStore(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj)); + var resolvedAccount = Assert.IsType(serviceAccountObj); + Assert.Equal("svc-observer", resolvedAccount.AccountId); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Contains("jobs:read", grantedScopes); + Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId); + Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 1; + }); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-observer", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "existing-token", + Status = "valid", + Tenant = "tenant-alpha", + ClientId = clientDocument.ClientId, + ServiceAccountId = "svc-observer", + TokenKind = AuthorityTokenKinds.ServiceAccount, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), + Scope = new List { "jobs:read" } + } + }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + metadataAccessor, + new TestServiceAccountStore(serviceAccount), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + 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); + + Assert.False(context.IsRejected); + Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]); + Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]); + Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]); + + var metadata = metadataAccessor.GetMetadata(); + Assert.NotNull(metadata); + Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag)); + Assert.Equal("prod", envTag); + } + + [Fact] + public async Task HandleClientCredentials_PersistsVulnAttributes() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var tokenStore = new TestTokenStore(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + transaction.Options = new OpenIddictServerOptions + { + AccessTokenLifetime = TimeSpan.FromMinutes(5) + }; + 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); + Assert.False(validateContext.IsRejected); + + var sessionAccessor = new NullMongoSessionAccessor(); + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + + Assert.True(handleContext.IsRequestHandled); + var principal = Assert.IsType(handleContext.Principal); + Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment)); + Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner)); + Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier)); + + var persistHandler = new PersistTokensHandler( + tokenStore, + sessionAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await persistHandler.HandleAsync(signInContext); + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment); + Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner); + Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:read aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "signals:read signals:write signals:admin aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Theory] + [InlineData(StellaOpsScopes.AirgapSeal)] + [InlineData(StellaOpsScopes.AirgapImport)] + [InlineData(StellaOpsScopes.AirgapStatusRead)] + public async Task ValidateClientCredentials_RejectsAirgapScopesWithoutTenant(string scope) + { + var clientId = $"airgap-{scope.Replace(':', '-')}-client"; + var clientDocument = CreateClient( + clientId: clientId, + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: scope); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Air-gap scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(scope, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Theory] + [InlineData(StellaOpsScopes.AirgapSeal)] + [InlineData(StellaOpsScopes.AirgapImport)] + [InlineData(StellaOpsScopes.AirgapStatusRead)] + public async Task ValidateClientCredentials_AllowsAirgapScopesWithTenant(string scope) + { + var clientId = $"airgap-{scope.Replace(':', '-')}-client"; + var clientDocument = CreateClient( + clientId: clientId, + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: scope, + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { scope }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:author"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:author", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "policy:author" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:publish", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:promote", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:read aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "aoc:verify"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant)); + Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "effective:write" }, grantedScopes); + + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill actions require 'backfill_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill actions require 'backfill_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longReason = new string('a', 257); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill reason must not exceed 256 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longTicket = new string('b', 129); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill ticket must not exceed 128 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_PopulatesBackfillMetadata_OnSuccess() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:backfill" }, grantedScopes); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty]); + Assert.Equal("Backfill drift repair", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty]); + Assert.Equal("INC-9981", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:operate" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); + Assert.Equal("resume source after maintenance", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); + Assert.Equal("INC-2045", ticket); + Assert.Equal("resume source after maintenance", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); + Assert.Equal("INC-2045", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + 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); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota changes require 'quota_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longReason = new string('a', 257); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota reason must not exceed 256 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTicketTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + 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); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota ticket must not exceed 128 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonOnly() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:quota" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); + Assert.Equal("grant five extra concurrent backfills", reason); + Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.QuotaTicketProperty)); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + 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); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:quota" }, grantedScopes); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); + Assert.Equal("temporary burst for export audit", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaTicketProperty]); + Assert.Equal("RFC-5541", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "export-viewer", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.viewer"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant() + { + var clientDocument = CreateClient( + clientId: "export-viewer", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.viewer", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "export.viewer" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPacksRun_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "task-runner", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "packs.run packs.read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + string? violationTag = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == TestActivitySource.Name, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + violationTag ??= activity.GetTagItem("authority.pack_scope_violation") as string; + } + }; + ActivitySource.AddActivityListener(listener); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Pack scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PacksRun, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Equal(StellaOpsScopes.PacksRun, violationTag); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsPacksRun_WithTenant() + { + var clientDocument = CreateClient( + clientId: "task-runner", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "packs.run packs.read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "packs.run" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + 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); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "export.admin" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]); + Assert.Equal("Rotate encryption keys after incident postmortem", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]); + Assert.Equal("INC-9001", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "graph-api", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:read graph:export"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant() + { + var clientDocument = CreateClient( + clientId: "graph-api", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:read graph:export", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "graph:read" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-dashboard", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant() + { + var clientDocument = CreateClient( + clientId: "orch-dashboard", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:read" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "concelier-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "excitor-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vex:ingest vex:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "vuln-explorer-ui", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view vuln:investigate"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant() + { + var clientDocument = CreateClient( + clientId: "concelier-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read aoc:verify", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "graph:write" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var sink = new TestAuthEventSink(); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + sink, + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction,"unexpected_param", "value"); + + await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + + var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); + Assert.Contains(tamperEvent.Properties, property => + string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && + string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ValidateDpopProof_AllowsSenderConstrainedClient() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); + var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); + httpContext.Request.Headers["DPoP"] = proof; + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + + Assert.False(validateContext.IsRejected); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var serviceAccountStore = new TestServiceAccountStore(); + var validateHandler = new ValidateClientCredentialsHandler( + clientStore, + registry, + TestActivitySource, + auditSink, + rateMetadata, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var sessionAccessor = new NullMongoSessionAccessor(); + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + rateMetadata, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + Assert.True(handleContext.IsRequestHandled); + + var persistHandler = new PersistTokensHandler( + tokenStore, + sessionAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = handleContext.Principal, + AccessTokenPrincipal = handleContext.Principal + }; + + await persistHandler.HandleAsync(signInContext); + + var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmationClaim)); + + using (var confirmationJson = JsonDocument.Parse(confirmationClaim!)) + { + Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); + } + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint); + Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint); + } + + [Fact] + public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Dpop.Enabled = true; + options.Security.SenderConstraints.Dpop.Nonce.Enabled = true; + options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); + options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); + httpContext.Request.Headers["DPoP"] = proof; + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + + Assert.True(validateContext.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); + 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)); + Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge"); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; + + using var rsa = RSA.Create(2048); + var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = hexThumbprint + }); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var auditSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(); + var tokenStore = new TestTokenStore(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + auditSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + validator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, context.ErrorDescription ?? context.Error); + Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]); + + var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256)); + Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + validator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + } + + [Fact] + public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Mtls.Enabled = true; + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Mtls.Enabled = true; + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = "DEADBEEF" + }); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var certificateValidator = new RecordingCertificateValidator(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + certificateValidator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("client_certificate_required", context.ErrorDescription); + Assert.True(certificateValidator.Invoked); + } + + [Fact] + public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims() + { + var clientDocument = CreateClient( + secret: null, + clientType: "public", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:trigger", + allowedAudiences: "signer", + tenant: "Tenant-Alpha"); + + var descriptor = CreateDescriptor(clientDocument); + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(); + var options = TestHelpers.CreateAuthorityOptions(); + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); + transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); + + var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRequestHandled); + Assert.NotNull(context.Principal); + Assert.Contains("signer", context.Principal!.GetAudiences()); + + Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success); + + var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider); + Assert.Equal(clientDocument.Plugin, identityProviderClaim); + + var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); + Assert.False(string.IsNullOrWhiteSpace(tokenId)); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await persistHandler.HandleAsync(signInContext); + + var persisted = Assert.IsType(tokenStore.Inserted); + Assert.Equal(tokenId, persisted.TokenId); + Assert.Equal(clientDocument.ClientId, persisted.ClientId); + Assert.Equal("valid", persisted.Status); + Assert.Equal("tenant-alpha", persisted.Tenant); + Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope); + } + + [Fact] + public async Task HandleClientCredentials_PersistsServiceAccountMetadata() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-ops", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + Assert.True(handleContext.IsRequestHandled); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = handleContext.Principal, + AccessTokenPrincipal = handleContext.Principal + }; + + await persistHandler.HandleAsync(signInContext); + + var inserted = tokenStore.Inserted; + Assert.NotNull(inserted); + Assert.Equal("svc-ops", inserted!.ServiceAccountId); + Assert.Equal("service_account", inserted.TokenKind); + Assert.NotNull(inserted.ActorChain); + Assert.Contains(clientDocument.ClientId, inserted.ActorChain!); + Assert.Equal("tenant-alpha", inserted.Tenant); + Assert.Contains("jobs:read", inserted.Scope); } [Fact] @@ -3000,1528 +3000,1528 @@ public class ClientCredentialsHandlersTests { var clientDocument = CreateClient( clientId: "vuln-explorer-worker", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view vuln:investigate", - tenant: "tenant-alpha"); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view", "vuln:investigate" }, - AuthorizedClients = new List { clientDocument.ClientId }, - Attributes = new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - ["env"] = new List { "Prod", "stage" }, - ["owner"] = new List { "SecOps" }, - ["business_tier"] = new List { "*" }, - ["ignored"] = new List { "value" } - } - }; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate"); - 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); - Assert.False(validateContext.IsRejected); - - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - - Assert.True(handleContext.IsRequestHandled); - var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing"); - - var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "prod" }, envClaims); - - var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "secops" }, ownerClaims); - - var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "tier-1" }, tierClaims); - } -} - -public class TokenValidationHandlersTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation"); - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() - { - var tokenStore = new TestTokenStore(); - tokenStore.Inserted = new AuthorityTokenDocument - { - TokenId = "token-1", - Status = "revoked", - ClientId = "concelier" - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(CreateClient()), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal("concelier", "token-1", "standard"); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-1" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha" - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); - } - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha" - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); - } - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers() - { - var clientDocument = CreateClient(tenant: "tenant-beta"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable() - { - var clientDocument = CreateClient(); - var userDescriptor = new AuthorityUserDescriptor("user-1", "alice", displayName: "Alice", requiresPasswordReset: false); - - var plugin = CreatePlugin( - name: "standard", - supportsClientProvisioning: true, - descriptor: CreateDescriptor(clientDocument), - user: userDescriptor); - - var registry = CreateRegistryFromPlugins(plugin); - - var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor(); - var auditSinkSuccess = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - new TestTokenStore(), - sessionAccessor, - new TestClientStore(clientDocument), - registry, - metadataAccessorSuccess, - auditSinkSuccess, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-123", plugin.Name, subject: userDescriptor.SubjectId); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true"); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken() - { - var tokenDocument = new AuthorityTokenDocument - { - TokenId = "token-mtls", - Status = "valid", - ClientId = "mtls-client", - SenderConstraint = AuthoritySenderConstraintKinds.Mtls, - SenderKeyThumbprint = "thumb-print" - }; - - var tokenStore = new TestTokenStore - { - Inserted = tokenDocument - }; - - var clientDocument = CreateClient(); - var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - registry, - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Introspection, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = tokenDocument.TokenId - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); - Assert.False(string.IsNullOrWhiteSpace(confirmation)); - using var json = JsonDocument.Parse(confirmation!); - Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString()); - } - - [Fact] - public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay() - { - var tokenStore = new TestTokenStore(); - tokenStore.Inserted = new AuthorityTokenDocument - { - TokenId = "token-replay", - Status = "valid", - ClientId = "agent", - Devices = new List - { - new BsonDocument - { - { "remoteAddress", "10.0.0.1" }, - { "userAgent", "agent/1.0" }, - { "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) }, - { "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) }, - { "useCount", 2 } - } - } - }; - - tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent); - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var metadata = metadataAccessor.GetMetadata(); - if (metadata is not null) - { - metadata.RemoteIp = "203.0.113.7"; - metadata.UserAgent = "agent/2.0"; - } - - var clientDocument = CreateClient(); - clientDocument.ClientId = "agent"; - var auditSink = new TestAuthEventSink(); - var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); - var sessionAccessorReplay = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessorReplay, - new TestClientStore(clientDocument), - registry, - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Introspection, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal("agent", "token-replay", "standard"); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-replay" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected"); - Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome); - Assert.NotNull(replayEvent.Network); - Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value); - Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total"); - } -} - -public class AuthorityClientCertificateValidatorTests -{ - [Fact] - public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri"); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)) - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_san_type", result.Error); - } - - [Fact] - public async Task ValidateAsync_AllowsBindingWithinRotationGrace() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10)); - - var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = thumbprint, - NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2) - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.True(result.Succeeded); - Assert.Equal(thumbprint, result.HexThumbprint); - } - - [Fact] - public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), - Subject = "CN=different-client" - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_binding_subject_mismatch", result.Error); - } - - [Fact] - public async Task ValidateAsync_Rejects_WhenBindingSansMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), - SubjectAlternativeNames = new List { "spiffe://client" } - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_binding_san_mismatch", result.Error); - } -} - -internal sealed class TestClientStore : IAuthorityClientStore -{ - private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase); - - public TestClientStore(params AuthorityClientDocument[] documents) - { - foreach (var document in documents) - { - clients[document.ClientId] = document; - } - } - - public ValueTask 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, IClientSessionHandle? session = null) - { - clients[document.ClientId] = document; - return ValueTask.CompletedTask; - } - - public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(clients.Remove(clientId)); -} - -internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore -{ - private readonly Dictionary accounts = new(StringComparer.OrdinalIgnoreCase); - - public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents) - { - foreach (var document in documents) - { - accounts[NormalizeKey(document.AccountId)] = document; - } - } - - public ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(accountId)) - { - return ValueTask.FromResult(null); - } - - accounts.TryGetValue(NormalizeKey(accountId), out var document); - return ValueTask.FromResult(document); - } - - public ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(tenant)) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var normalizedTenant = tenant.Trim().ToLowerInvariant(); - var results = accounts.Values - .Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - return ValueTask.FromResult>(results); - } - - public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - ArgumentNullException.ThrowIfNull(document); - accounts[NormalizeKey(document.AccountId)] = document; - return ValueTask.CompletedTask; - } - - public ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(accountId)) - { - return ValueTask.FromResult(false); - } - - return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId))); - } - - private static string NormalizeKey(string value) - => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant(); -} - -internal sealed class TestTokenStore : IAuthorityTokenStore -{ - public AuthorityTokenDocument? Inserted { get; set; } - - public Func? UsageCallback { get; set; } - - public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - Inserted = document; - return ValueTask.CompletedTask; - } - - public ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null); - - public ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(null); - - public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.CompletedTask; - - public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(0L); - - public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent)); - - public ValueTask> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult>(Array.Empty()); - public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var scopeMatches = Inserted.Scope is not null && Inserted.Scope.Any(s => string.Equals(s, scope, StringComparison.OrdinalIgnoreCase)); - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var issuedAfterMatches = !issuedAfter.HasValue || Inserted.CreatedAt >= issuedAfter.Value; - - if (scopeMatches && tenantMatches && issuedAfterMatches) - { - return ValueTask.FromResult>(new[] { Inserted }); - } - - return ValueTask.FromResult>(Array.Empty()); - } - - public ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult(0L); - } - - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || - string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); - var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && - (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && - !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && - string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); - - return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L); - } - - public ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || - string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); - var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && - (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && - !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && - string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); - - if (tenantMatches && accountMatches && active) - { - return ValueTask.FromResult>(new[] { Inserted }); - } - - return ValueTask.FromResult>(Array.Empty()); - } - -} - -internal sealed class TestClaimsEnricher : IClaimsEnricher -{ - public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) - { - if (!identity.HasClaim(c => c.Type == "enriched")) - { - identity.AddClaim(new Claim("enriched", "true")); - } - - return ValueTask.CompletedTask; - } -} - -internal sealed class TestUserCredentialStore : IUserCredentialStore -{ - private readonly AuthorityUserDescriptor? user; - - public TestUserCredentialStore(AuthorityUserDescriptor? user) - { - this.user = user; - } - - public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials)); - - public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); - - public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) - => ValueTask.FromResult(user); -} - -internal sealed class TestClientProvisioningStore : IClientProvisioningStore -{ - private readonly AuthorityClientDescriptor? descriptor; - - public TestClientProvisioningStore(AuthorityClientDescriptor? descriptor) - { - this.descriptor = descriptor; - } - - public ValueTask> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); - - public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) - => ValueTask.FromResult(descriptor); - - public ValueTask DeleteAsync(string clientId, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Success()); -} - -internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin -{ - public TestIdentityProviderPlugin( - AuthorityPluginContext context, - IUserCredentialStore credentialStore, - IClaimsEnricher claimsEnricher, - IClientProvisioningStore? clientProvisioning, - AuthorityIdentityProviderCapabilities capabilities) - { - Context = context; - Credentials = credentialStore; - ClaimsEnricher = claimsEnricher; - ClientProvisioning = clientProvisioning; - Capabilities = capabilities; - } - - public string Name => Context.Manifest.Name; - - public string Type => Context.Manifest.Type; - - public AuthorityPluginContext Context { get; } - - public IUserCredentialStore Credentials { get; } - - public IClaimsEnricher ClaimsEnricher { get; } - - public IClientProvisioningStore? ClientProvisioning { get; } - - public AuthorityIdentityProviderCapabilities Capabilities { get; } - - public ValueTask CheckHealthAsync(CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); -} - -internal sealed class TestAuthEventSink : IAuthEventSink -{ - public List Events { get; } = new(); - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - Events.Add(record); - return ValueTask.CompletedTask; - } -} - -internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor -{ - private readonly AuthorityRateLimiterMetadata metadata = new(); - - public AuthorityRateLimiterMetadata? GetMetadata() => metadata; - - public void SetClientId(string? clientId) => metadata.ClientId = clientId; - - public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; - - public void SetTenant(string? tenant) - { - metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant(); - metadata.SetTag("authority.tenant", metadata.Tenant); - } - - public void SetProject(string? project) - { - metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant(); - metadata.SetTag("authority.project", metadata.Project); - } - - public void SetTag(string name, string? value) => metadata.SetTag(name, value); -} - -internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator -{ - public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) - { - var binding = new AuthorityClientCertificateBinding - { - Thumbprint = "stub" - }; - - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding)); - } -} - -internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator -{ - public bool Invoked { get; private set; } - - public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) - { - Invoked = true; - - if (httpContext.Connection.ClientCertificate is null) - { - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required")); - } - - AuthorityClientCertificateBinding binding; - if (client.CertificateBindings.Count > 0) - { - binding = client.CertificateBindings[0]; - } - else - { - binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" }; - } - - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding)); - } -} - -internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor -{ - public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) - => ValueTask.FromResult(null!); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} - -public class ObservabilityIncidentTokenHandlerTests -{ - private static readonly ActivitySource ActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenObsIncidentNotFresh() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-incident", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.ObservabilityIncident }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15) - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-incident" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-policy", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.PolicyPublish }, - CreatedAt = DateTimeOffset.UtcNow - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000"); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-policy" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-policy-stale", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.PolicyPublish }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20) - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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)); - principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001"); - var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-policy-stale" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Theory] - [InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)] - [InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] - public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation) - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = $"token-{expectedOperation}", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { scope }, - CreatedAt = DateTimeOffset.UtcNow - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - 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)); - principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002"); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = $"token-{expectedOperation}" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var metadata = metadataAccessor.GetMetadata(); - Assert.NotNull(metadata); - Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue)); - Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue); - } - - [Fact] - public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() - { - var handler = new ValidateRefreshTokenGrantHandler(NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.RefreshToken - } - }; - - var principal = CreatePrincipal("cli-app", "refresh-token", "standard"); - principal.SetScopes(StellaOpsScopes.ObservabilityIncident); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) - { - Principal = principal - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidGrant, context.Error); - } -} - -internal static class TestHelpers -{ - public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? configure = null) - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - configure?.Invoke(options); - return options; - } - - public static AuthorityClientDocument CreateClient( - string clientId = "concelier", - string? secret = "s3cr3t!", - string clientType = "confidential", - string allowedGrantTypes = "client_credentials", - string allowedScopes = "jobs:read", - string allowedAudiences = "", - string? tenant = null) - { - var document = new AuthorityClientDocument - { - ClientId = clientId, - ClientType = clientType, - SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret), - Plugin = "standard", - Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [AuthorityClientMetadataKeys.AllowedGrantTypes] = allowedGrantTypes, - [AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes - } - }; - - if (!string.IsNullOrWhiteSpace(allowedAudiences)) - { - document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences; - } - - var normalizedTenant = NormalizeTenant(tenant); - if (normalizedTenant is not null) - { - document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; - } - - return document; - } - - 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(); - var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); - var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); - - return new AuthorityClientDescriptor( - document.ClientId, - document.DisplayName, - confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase), - allowedGrantTypes, - allowedScopes, - allowedAudiences, - redirectUris: Array.Empty(), - postLogoutRedirectUris: Array.Empty(), - properties: document.Properties); - } - - public static AuthorityIdentityProviderRegistry CreateRegistry(bool withClientProvisioning, AuthorityClientDescriptor? clientDescriptor) - { - var plugin = CreatePlugin( - name: "standard", - supportsClientProvisioning: withClientProvisioning, - descriptor: clientDescriptor, - user: null); - - return CreateRegistryFromPlugins(plugin); - } - - public static TestIdentityProviderPlugin CreatePlugin( - string name, - bool supportsClientProvisioning, - AuthorityClientDescriptor? descriptor, - AuthorityUserDescriptor? user) - { - var capabilities = supportsClientProvisioning - ? new[] { AuthorityPluginCapabilities.ClientProvisioning } - : Array.Empty(); - - var manifest = new AuthorityPluginManifest( - name, - "standard", - true, - null, - null, - capabilities, - new Dictionary(StringComparer.OrdinalIgnoreCase), - $"{name}.yaml"); - - var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); - - return new TestIdentityProviderPlugin( - context, - new TestUserCredentialStore(user), - new TestClaimsEnricher(), - supportsClientProvisioning ? new TestClientProvisioningStore(descriptor) : null, - new AuthorityIdentityProviderCapabilities( - SupportsPassword: true, - SupportsMfa: false, - SupportsClientProvisioning: supportsClientProvisioning)); - } - - public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) - { - var services = new ServiceCollection(); - services.AddLogging(); - foreach (var plugin in plugins) - { - services.AddSingleton(plugin); - } - - var provider = services.BuildServiceProvider(); - return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); - } - - public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope) - { - var request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.ClientCredentials, - ClientId = clientId, - ClientSecret = secret - }; - - if (!string.IsNullOrWhiteSpace(scope)) - { - request.Scope = scope; - } - - return new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = request - }; - } - - public static string ConvertThumbprintToString(object thumbprint) - => thumbprint switch - { - string value => value, - byte[] bytes => Base64UrlEncoder.Encode(bytes), - _ => throw new InvalidOperationException("Unsupported thumbprint representation.") - }; - - public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null) - { - var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key); - jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N"); - - var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); - var header = new JwtHeader(signingCredentials) - { - ["typ"] = "dpop+jwt", - ["jwk"] = new Dictionary - { - ["kty"] = jwk.Kty, - ["crv"] = jwk.Crv, - ["x"] = jwk.X, - ["y"] = jwk.Y, - ["kid"] = jwk.Kid ?? jwk.KeyId - } - }; - - var payload = new JwtPayload - { - ["htm"] = method.ToUpperInvariant(), - ["htu"] = url, - ["iat"] = issuedAt, - ["jti"] = Guid.NewGuid().ToString("N") - }; - - if (!string.IsNullOrWhiteSpace(nonce)) - { - payload["nonce"] = nonce; - } - - var token = new JwtSecurityToken(header, payload); - return new JwtSecurityTokenHandler().WriteToken(token); - } - - public static X509Certificate2 CreateTestCertificate(string subjectName) - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); - } - - public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null) - { - var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); - identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId)); - identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider)); - identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject)); - - if (!string.IsNullOrWhiteSpace(subject)) - { - identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, subject)); - } - - return new ClaimsPrincipal(identity); - } -} + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view vuln:investigate", + tenant: "tenant-alpha"); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view", "vuln:investigate" }, + AuthorizedClients = new List { clientDocument.ClientId }, + Attributes = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["env"] = new List { "Prod", "stage" }, + ["owner"] = new List { "SecOps" }, + ["business_tier"] = new List { "*" }, + ["ignored"] = new List { "value" } + } + }; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate"); + 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); + Assert.False(validateContext.IsRejected); + + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + + Assert.True(handleContext.IsRequestHandled); + var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing"); + + var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "prod" }, envClaims); + + var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "secops" }, ownerClaims); + + var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "tier-1" }, tierClaims); + } +} + +public class TokenValidationHandlersTests +{ + private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation"); + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() + { + var tokenStore = new TestTokenStore(); + tokenStore.Inserted = new AuthorityTokenDocument + { + TokenId = "token-1", + Status = "revoked", + ClientId = "concelier" + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(CreateClient()), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal("concelier", "token-1", "standard"); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-1" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); + } + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); + } + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers() + { + var clientDocument = CreateClient(tenant: "tenant-beta"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable() + { + var clientDocument = CreateClient(); + var userDescriptor = new AuthorityUserDescriptor("user-1", "alice", displayName: "Alice", requiresPasswordReset: false); + + var plugin = CreatePlugin( + name: "standard", + supportsClientProvisioning: true, + descriptor: CreateDescriptor(clientDocument), + user: userDescriptor); + + var registry = CreateRegistryFromPlugins(plugin); + + var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor(); + var auditSinkSuccess = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + new TestTokenStore(), + sessionAccessor, + new TestClientStore(clientDocument), + registry, + metadataAccessorSuccess, + auditSinkSuccess, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-123", plugin.Name, subject: userDescriptor.SubjectId); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true"); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken() + { + var tokenDocument = new AuthorityTokenDocument + { + TokenId = "token-mtls", + Status = "valid", + ClientId = "mtls-client", + SenderConstraint = AuthoritySenderConstraintKinds.Mtls, + SenderKeyThumbprint = "thumb-print" + }; + + var tokenStore = new TestTokenStore + { + Inserted = tokenDocument + }; + + var clientDocument = CreateClient(); + var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + registry, + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Introspection, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = tokenDocument.TokenId + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmation)); + using var json = JsonDocument.Parse(confirmation!); + Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString()); + } + + [Fact] + public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay() + { + var tokenStore = new TestTokenStore(); + tokenStore.Inserted = new AuthorityTokenDocument + { + TokenId = "token-replay", + Status = "valid", + ClientId = "agent", + Devices = new List + { + new BsonDocument + { + { "remoteAddress", "10.0.0.1" }, + { "userAgent", "agent/1.0" }, + { "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) }, + { "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) }, + { "useCount", 2 } + } + } + }; + + tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent); + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var metadata = metadataAccessor.GetMetadata(); + if (metadata is not null) + { + metadata.RemoteIp = "203.0.113.7"; + metadata.UserAgent = "agent/2.0"; + } + + var clientDocument = CreateClient(); + clientDocument.ClientId = "agent"; + var auditSink = new TestAuthEventSink(); + var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); + var sessionAccessorReplay = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessorReplay, + new TestClientStore(clientDocument), + registry, + metadataAccessor, + auditSink, + TimeProvider.System, + TestActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Introspection, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal("agent", "token-replay", "standard"); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-replay" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected"); + Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome); + Assert.NotNull(replayEvent.Network); + Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value); + Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total"); + } +} + +public class AuthorityClientCertificateValidatorTests +{ + [Fact] + public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri"); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_san_type", result.Error); + } + + [Fact] + public async Task ValidateAsync_AllowsBindingWithinRotationGrace() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10)); + + var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = thumbprint, + NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(thumbprint, result.HexThumbprint); + } + + [Fact] + public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), + Subject = "CN=different-client" + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_binding_subject_mismatch", result.Error); + } + + [Fact] + public async Task ValidateAsync_Rejects_WhenBindingSansMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), + SubjectAlternativeNames = new List { "spiffe://client" } + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_binding_san_mismatch", result.Error); + } +} + +internal sealed class TestClientStore : IAuthorityClientStore +{ + private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase); + + public TestClientStore(params AuthorityClientDocument[] documents) + { + foreach (var document in documents) + { + clients[document.ClientId] = document; + } + } + + public ValueTask 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, IClientSessionHandle? session = null) + { + clients[document.ClientId] = document; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(clients.Remove(clientId)); +} + +internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore +{ + private readonly Dictionary accounts = new(StringComparer.OrdinalIgnoreCase); + + public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents) + { + foreach (var document in documents) + { + accounts[NormalizeKey(document.AccountId)] = document; + } + } + + public ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(accountId)) + { + return ValueTask.FromResult(null); + } + + accounts.TryGetValue(NormalizeKey(accountId), out var document); + return ValueTask.FromResult(document); + } + + public ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(tenant)) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var normalizedTenant = tenant.Trim().ToLowerInvariant(); + var results = accounts.Values + .Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return ValueTask.FromResult>(results); + } + + public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + ArgumentNullException.ThrowIfNull(document); + accounts[NormalizeKey(document.AccountId)] = document; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(accountId)) + { + return ValueTask.FromResult(false); + } + + return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId))); + } + + private static string NormalizeKey(string value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant(); +} + +internal sealed class TestTokenStore : IAuthorityTokenStore +{ + public AuthorityTokenDocument? Inserted { get; set; } + + public Func? UsageCallback { get; set; } + + public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + Inserted = document; + return ValueTask.CompletedTask; + } + + public ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null); + + public ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(null); + + public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.CompletedTask; + + public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(0L); + + public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent)); + + public ValueTask> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult>(Array.Empty()); + public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var scopeMatches = Inserted.Scope is not null && Inserted.Scope.Any(s => string.Equals(s, scope, StringComparison.OrdinalIgnoreCase)); + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var issuedAfterMatches = !issuedAfter.HasValue || Inserted.CreatedAt >= issuedAfter.Value; + + if (scopeMatches && tenantMatches && issuedAfterMatches) + { + return ValueTask.FromResult>(new[] { Inserted }); + } + + return ValueTask.FromResult>(Array.Empty()); + } + + public ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult(0L); + } + + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || + string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); + var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && + (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && + !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && + string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); + + return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L); + } + + public ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || + string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); + var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && + (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && + !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && + string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); + + if (tenantMatches && accountMatches && active) + { + return ValueTask.FromResult>(new[] { Inserted }); + } + + return ValueTask.FromResult>(Array.Empty()); + } + +} + +internal sealed class TestClaimsEnricher : IClaimsEnricher +{ + public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) + { + if (!identity.HasClaim(c => c.Type == "enriched")) + { + identity.AddClaim(new Claim("enriched", "true")); + } + + return ValueTask.CompletedTask; + } +} + +internal sealed class TestUserCredentialStore : IUserCredentialStore +{ + private readonly AuthorityUserDescriptor? user; + + public TestUserCredentialStore(AuthorityUserDescriptor? user) + { + this.user = user; + } + + public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials)); + + public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); + + public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) + => ValueTask.FromResult(user); +} + +internal sealed class TestClientProvisioningStore : IClientProvisioningStore +{ + private readonly AuthorityClientDescriptor? descriptor; + + public TestClientProvisioningStore(AuthorityClientDescriptor? descriptor) + { + this.descriptor = descriptor; + } + + public ValueTask> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); + + public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) + => ValueTask.FromResult(descriptor); + + public ValueTask DeleteAsync(string clientId, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Success()); +} + +internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin +{ + public TestIdentityProviderPlugin( + AuthorityPluginContext context, + IUserCredentialStore credentialStore, + IClaimsEnricher claimsEnricher, + IClientProvisioningStore? clientProvisioning, + AuthorityIdentityProviderCapabilities capabilities) + { + Context = context; + Credentials = credentialStore; + ClaimsEnricher = claimsEnricher; + ClientProvisioning = clientProvisioning; + Capabilities = capabilities; + } + + public string Name => Context.Manifest.Name; + + public string Type => Context.Manifest.Type; + + public AuthorityPluginContext Context { get; } + + public IUserCredentialStore Credentials { get; } + + public IClaimsEnricher ClaimsEnricher { get; } + + public IClientProvisioningStore? ClientProvisioning { get; } + + public AuthorityIdentityProviderCapabilities Capabilities { get; } + + public ValueTask CheckHealthAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); +} + +internal sealed class TestAuthEventSink : IAuthEventSink +{ + public List Events { get; } = new(); + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + Events.Add(record); + return ValueTask.CompletedTask; + } +} + +internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor +{ + private readonly AuthorityRateLimiterMetadata metadata = new(); + + public AuthorityRateLimiterMetadata? GetMetadata() => metadata; + + public void SetClientId(string? clientId) => metadata.ClientId = clientId; + + public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; + + public void SetTenant(string? tenant) + { + metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant(); + metadata.SetTag("authority.tenant", metadata.Tenant); + } + + public void SetProject(string? project) + { + metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant(); + metadata.SetTag("authority.project", metadata.Project); + } + + public void SetTag(string name, string? value) => metadata.SetTag(name, value); +} + +internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator +{ + public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) + { + var binding = new AuthorityClientCertificateBinding + { + Thumbprint = "stub" + }; + + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding)); + } +} + +internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator +{ + public bool Invoked { get; private set; } + + public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) + { + Invoked = true; + + if (httpContext.Connection.ClientCertificate is null) + { + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required")); + } + + AuthorityClientCertificateBinding binding; + if (client.CertificateBindings.Count > 0) + { + binding = client.CertificateBindings[0]; + } + else + { + binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" }; + } + + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding)); + } +} + +internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor +{ + public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(null!); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +public class ObservabilityIncidentTokenHandlerTests +{ + private static readonly ActivitySource ActivitySource = new("StellaOps.Authority.Tests"); + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenObsIncidentNotFresh() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-incident", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.ObservabilityIncident }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15) + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-incident" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-policy", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.PolicyPublish }, + CreatedAt = DateTimeOffset.UtcNow + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000"); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-policy" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-policy-stale", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.PolicyPublish }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20) + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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)); + principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001"); + var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-policy-stale" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Theory] + [InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)] + [InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] + public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation) + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = $"token-{expectedOperation}", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { scope }, + CreatedAt = DateTimeOffset.UtcNow + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + 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)); + principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002"); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = $"token-{expectedOperation}" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var metadata = metadataAccessor.GetMetadata(); + Assert.NotNull(metadata); + Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue)); + Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue); + } + + [Fact] + public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() + { + var handler = new ValidateRefreshTokenGrantHandler(NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + EndpointType = OpenIddictServerEndpointType.Token, + Options = new OpenIddictServerOptions(), + Request = new OpenIddictRequest + { + GrantType = OpenIddictConstants.GrantTypes.RefreshToken + } + }; + + var principal = CreatePrincipal("cli-app", "refresh-token", "standard"); + principal.SetScopes(StellaOpsScopes.ObservabilityIncident); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) + { + Principal = principal + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidGrant, context.Error); + } +} + +internal static class TestHelpers +{ + public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? configure = null) + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + configure?.Invoke(options); + return options; + } + + public static AuthorityClientDocument CreateClient( + string clientId = "concelier", + string? secret = "s3cr3t!", + string clientType = "confidential", + string allowedGrantTypes = "client_credentials", + string allowedScopes = "jobs:read", + string allowedAudiences = "", + string? tenant = null) + { + var document = new AuthorityClientDocument + { + ClientId = clientId, + ClientType = clientType, + SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret), + Plugin = "standard", + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AuthorityClientMetadataKeys.AllowedGrantTypes] = allowedGrantTypes, + [AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes + } + }; + + if (!string.IsNullOrWhiteSpace(allowedAudiences)) + { + document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences; + } + + var normalizedTenant = NormalizeTenant(tenant); + if (normalizedTenant is not null) + { + document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; + } + + return document; + } + + 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(); + var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); + var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); + + return new AuthorityClientDescriptor( + document.ClientId, + document.DisplayName, + confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase), + allowedGrantTypes, + allowedScopes, + allowedAudiences, + redirectUris: Array.Empty(), + postLogoutRedirectUris: Array.Empty(), + properties: document.Properties); + } + + public static AuthorityIdentityProviderRegistry CreateRegistry(bool withClientProvisioning, AuthorityClientDescriptor? clientDescriptor) + { + var plugin = CreatePlugin( + name: "standard", + supportsClientProvisioning: withClientProvisioning, + descriptor: clientDescriptor, + user: null); + + return CreateRegistryFromPlugins(plugin); + } + + public static TestIdentityProviderPlugin CreatePlugin( + string name, + bool supportsClientProvisioning, + AuthorityClientDescriptor? descriptor, + AuthorityUserDescriptor? user) + { + var capabilities = supportsClientProvisioning + ? new[] { AuthorityPluginCapabilities.ClientProvisioning } + : Array.Empty(); + + var manifest = new AuthorityPluginManifest( + name, + "standard", + true, + null, + null, + capabilities, + new Dictionary(StringComparer.OrdinalIgnoreCase), + $"{name}.yaml"); + + var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); + + return new TestIdentityProviderPlugin( + context, + new TestUserCredentialStore(user), + new TestClaimsEnricher(), + supportsClientProvisioning ? new TestClientProvisioningStore(descriptor) : null, + new AuthorityIdentityProviderCapabilities( + SupportsPassword: true, + SupportsMfa: false, + SupportsClientProvisioning: supportsClientProvisioning)); + } + + public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) + { + var services = new ServiceCollection(); + services.AddLogging(); + foreach (var plugin in plugins) + { + services.AddSingleton(plugin); + } + + var provider = services.BuildServiceProvider(); + return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); + } + + public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope) + { + var request = new OpenIddictRequest + { + GrantType = OpenIddictConstants.GrantTypes.ClientCredentials, + ClientId = clientId, + ClientSecret = secret + }; + + if (!string.IsNullOrWhiteSpace(scope)) + { + request.Scope = scope; + } + + return new OpenIddictServerTransaction + { + EndpointType = OpenIddictServerEndpointType.Token, + Options = new OpenIddictServerOptions(), + Request = request + }; + } + + public static string ConvertThumbprintToString(object thumbprint) + => thumbprint switch + { + string value => value, + byte[] bytes => Base64UrlEncoder.Encode(bytes), + _ => throw new InvalidOperationException("Unsupported thumbprint representation.") + }; + + public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null) + { + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key); + jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N"); + + var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); + var header = new JwtHeader(signingCredentials) + { + ["typ"] = "dpop+jwt", + ["jwk"] = new Dictionary + { + ["kty"] = jwk.Kty, + ["crv"] = jwk.Crv, + ["x"] = jwk.X, + ["y"] = jwk.Y, + ["kid"] = jwk.Kid ?? jwk.KeyId + } + }; + + var payload = new JwtPayload + { + ["htm"] = method.ToUpperInvariant(), + ["htu"] = url, + ["iat"] = issuedAt, + ["jti"] = Guid.NewGuid().ToString("N") + }; + + if (!string.IsNullOrWhiteSpace(nonce)) + { + payload["nonce"] = nonce; + } + + var token = new JwtSecurityToken(header, payload); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static X509Certificate2 CreateTestCertificate(string subjectName) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + } + + public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null) + { + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId)); + identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider)); + identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject)); + + if (!string.IsNullOrWhiteSpace(subject)) + { + identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, subject)); + } + + return new ClaimsPrincipal(identity); + } +} 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 7aa9d38e..d5d2a34a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/DiscoveryMetadataTests.cs @@ -1,48 +1,48 @@ -using System.Linq; -using System.Net; -using System.Text.Json; -using StellaOps.Authority.Tests.Infrastructure; -using StellaOps.Auth.Abstractions; -using Xunit; - -namespace StellaOps.Authority.Tests.OpenIddict; - -public sealed class DiscoveryMetadataTests : IClassFixture -{ - private readonly AuthorityWebApplicationFactory factory; - - public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory) - { - this.factory = factory; - } - - [Fact] - public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata() - { - using var client = factory.CreateClient(); - - using var response = await client.GetAsync("/.well-known/openid-configuration"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var payload = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(payload); - - var root = document.RootElement; - Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode)); - - var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray(); - Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes); - Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes); - Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes); - - Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode)); - Assert.False(remoteNode.GetProperty("enabled").GetBoolean()); - Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean()); - - var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray(); - Assert.Empty(profiles); - +using System.Linq; +using System.Net; +using System.Text.Json; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Auth.Abstractions; +using Xunit; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public sealed class DiscoveryMetadataTests : IClassFixture +{ + private readonly AuthorityWebApplicationFactory factory; + + public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory) + { + this.factory = factory; + } + + [Fact] + public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata() + { + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/.well-known/openid-configuration"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(payload); + + var root = document.RootElement; + Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode)); + + var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray(); + Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes); + Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes); + Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes); + + Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode)); + Assert.False(remoteNode.GetProperty("enabled").GetBoolean()); + Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean()); + + var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray(); + Assert.Empty(profiles); + Assert.True(root.TryGetProperty("stellaops_airgap_scopes_supported", out var airgapNode)); var airgapScopes = airgapNode.EnumerateArray().Select(element => element.GetString()).ToArray(); Assert.Contains(StellaOpsScopes.AirgapSeal, airgapScopes); @@ -61,10 +61,10 @@ public sealed class DiscoveryMetadataTests : IClassFixture -{ - private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero) - .UtcDateTime.ToString("r", CultureInfo.InvariantCulture); - - private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero) - .UtcDateTime.ToString("r", CultureInfo.InvariantCulture); - - private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero) - .ToString("O", CultureInfo.InvariantCulture); - - private readonly AuthorityWebApplicationFactory factory; - - public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory) - => this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); - - [Fact] - public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders() - { - using var client = factory.CreateClient(); - - using var response = await client.PostAsync( - "/oauth/token", - new FormUrlEncodedContent(new Dictionary - { - ["grant_type"] = "client_credentials" - })); - - Assert.NotNull(response); - Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues)); - Assert.Contains(ExpectedDeprecationHeader, deprecationValues); - - Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues)); - Assert.Contains(ExpectedSunsetHeader, sunsetValues); - - Assert.True(response.Headers.TryGetValues("Warning", out var warningValues)); - Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase)); - - Assert.True(response.Headers.TryGetValues("Link", out var linkValues)); - Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task LegacyTokenEndpoint_EmitsAuditEvent() - { - var sink = new RecordingAuthEventSink(); - - using var customFactory = factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - }); - }); - - using var client = customFactory.CreateClient(); - - using var response = await client.PostAsync( - "/oauth/token", - new FormUrlEncodedContent(new Dictionary - { - ["grant_type"] = "client_credentials" - })); - - Assert.NotNull(response); - - var record = Assert.Single(sink.Events); - Assert.Equal("authority.api.legacy_endpoint", record.EventType); - - Assert.Contains(record.Properties, property => - string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) && - string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal)); - - Assert.Contains(record.Properties, property => - string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) && - string.Equals(property.Value.Value, "/token", StringComparison.Ordinal)); - - Assert.Contains(record.Properties, property => - string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) && - string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal)); - } - - private sealed class RecordingAuthEventSink : IAuthEventSink - { - private readonly ConcurrentQueue events = new(); - - public IReadOnlyCollection Events => events.ToArray(); - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - events.Enqueue(record); - return ValueTask.CompletedTask; - } - } -} +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Cryptography.Audit; +using Xunit; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public sealed class LegacyAuthDeprecationTests : IClassFixture +{ + private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero) + .UtcDateTime.ToString("r", CultureInfo.InvariantCulture); + + private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero) + .UtcDateTime.ToString("r", CultureInfo.InvariantCulture); + + private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero) + .ToString("O", CultureInfo.InvariantCulture); + + private readonly AuthorityWebApplicationFactory factory; + + public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory) + => this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + + [Fact] + public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders() + { + using var client = factory.CreateClient(); + + using var response = await client.PostAsync( + "/oauth/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials" + })); + + Assert.NotNull(response); + Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues)); + Assert.Contains(ExpectedDeprecationHeader, deprecationValues); + + Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues)); + Assert.Contains(ExpectedSunsetHeader, sunsetValues); + + Assert.True(response.Headers.TryGetValues("Warning", out var warningValues)); + Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase)); + + Assert.True(response.Headers.TryGetValues("Link", out var linkValues)); + Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task LegacyTokenEndpoint_EmitsAuditEvent() + { + var sink = new RecordingAuthEventSink(); + + using var customFactory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + }); + }); + + using var client = customFactory.CreateClient(); + + using var response = await client.PostAsync( + "/oauth/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials" + })); + + Assert.NotNull(response); + + var record = Assert.Single(sink.Events); + Assert.Equal("authority.api.legacy_endpoint", record.EventType); + + Assert.Contains(record.Properties, property => + string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) && + string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal)); + + Assert.Contains(record.Properties, property => + string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) && + string.Equals(property.Value.Value, "/token", StringComparison.Ordinal)); + + Assert.Contains(record.Properties, property => + string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) && + string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal)); + } + + private sealed class RecordingAuthEventSink : IAuthEventSink + { + private readonly ConcurrentQueue events = new(); + + public IReadOnlyCollection Events => events.ToArray(); + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + events.Enqueue(record); + return ValueTask.CompletedTask; + } + } +} 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 a5b45e89..b02d7353 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs @@ -1,739 +1,739 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using OpenIddict.Abstractions; -using OpenIddict.Server; -using OpenIddict.Server.AspNetCore; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.OpenIddict.Handlers; -using StellaOps.Authority.Plugins.Abstractions; -using StellaOps.Authority.RateLimiting; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Cryptography.Audit; -using StellaOps.Configuration; -using StellaOps.Auth.Abstractions; -using Xunit; - -namespace StellaOps.Authority.Tests.OpenIddict; - -public class PasswordGrantHandlersTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task HandlePasswordGrant_EmitsSuccessAuditEvent() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!"); - - await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); - - var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); - Assert.Equal("tenant-alpha", successEvent.Tenant.Value); - - var metadata = metadataAccessor.GetMetadata(); - Assert.Equal("tenant-alpha", metadata?.Tenant); - } - - [Fact] - public async Task HandlePasswordGrant_EmitsFailureAuditEvent() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new FailureCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "BadPassword!"); - - await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); - - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsAdvisoryReadWithoutAocVerify() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("advisory:read aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsObsIncidentWithoutReason() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("obs:incident")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Contains("incident_reason", context.ErrorDescription); - } - - [Fact] - public async Task HandlePasswordGrant_AddsIncidentReasonAndAuthTime() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument("obs:incident"); - var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); - SetParameter(transaction, "incident_reason", "Sev1 drill activation"); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validate.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handle.HandleAsync(handleContext); - - Assert.False(handleContext.IsRejected); - var principal = Assert.IsType(handleContext.Principal); - Assert.Equal("Sev1 drill activation", principal.GetClaim(StellaOpsClaimTypes.IncidentReason)); - var authTimeClaim = principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime); - Assert.False(string.IsNullOrWhiteSpace(authTimeClaim)); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsAdvisoryAiViewWithoutAocVerify() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("advisory-ai:view aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsAdvisoryAiScopeWithoutTenant() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument("advisory-ai:view"); - clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); - var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Advisory AI scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AdvisoryAiView, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsSignalsScopeWithoutAocVerify() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("signals:write signals:read signals:admin aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "signals:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutReason() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - SetParameter(transaction, "policy_ticket", "CR-1001"); - SetParameter(transaction, "policy_digest", new string('a', 64)); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Policy attestation actions require 'policy_reason'.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => - record.EventType == "authority.password.grant" && - record.Outcome == AuthEventOutcome.Failure && - record.Properties.Any(property => property.Name == "policy.action")); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutTicket() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - 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); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Policy attestation actions require 'policy_ticket'.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutDigest() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - SetParameter(transaction, "policy_reason", "Publish approved policy"); - SetParameter(transaction, "policy_ticket", "CR-1002"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Policy attestation actions require 'policy_digest'.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsPolicyPublishWithInvalidDigest() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); - 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); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("policy_digest must be a hexadecimal string between 32 and 128 characters.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Theory] - [InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)] - [InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] - public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation) - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument(scope); - var clientStore = new StubClientStore(clientDocument); - - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", scope); - 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); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handle.HandleAsync(handleContext); - - Assert.False(handleContext.IsRejected); - var principal = Assert.IsType(handleContext.Principal); - Assert.Equal(expectedOperation, principal.GetClaim(StellaOpsClaimTypes.PolicyOperation)); - Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest)); - Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason)); - Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket)); - Assert.Contains(sink.Events, record => - record.EventType == "authority.password.grant" && - record.Outcome == AuthEventOutcome.Success && - record.Properties.Any(property => property.Name == "policy.action")); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument("policy:author"); - clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); - var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task ValidatePasswordGrant_AllowsPolicyAuthor() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("policy:author")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); - } - - [Fact] - public async Task HandlePasswordGrant_EmitsLockoutAuditEvent() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new LockoutCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Locked!"); - - await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); - - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.LockedOut); - } - - [Fact] - public async Task ValidatePasswordGrant_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!"); - SetParameter(transaction, "unexpected_param", "value"); - - await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - - var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); - Assert.Equal(AuthEventOutcome.Failure, tamperEvent.Outcome); - Assert.Contains(tamperEvent.Properties, property => - string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && - string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task ValidatePasswordGrant_RejectsExceptionsApprove_WhenMfaRequiredAndProviderLacksSupport() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: false); - var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve")); - var authorityOptions = CreateAuthorityOptions(opts => - { - opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions - { - Id = "secops", - AuthorityRouteId = "approvals/secops", - RequireMfa = true - }); - }); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Exception approval scope requires an MFA-capable identity provider.", context.ErrorDescription); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); - } - - [Fact] - public async Task HandlePasswordGrant_AllowsExceptionsApprove_WhenMfaSupported() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: true); - var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve")); - var authorityOptions = CreateAuthorityOptions(opts => - { - opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions - { - Id = "secops", - AuthorityRouteId = "approvals/secops", - RequireMfa = true - }); - }); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validate.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handle.HandleAsync(handleContext); - - Assert.False(handleContext.IsRejected); - Assert.NotNull(handleContext.Principal); - Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); - } - - private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store, bool supportsMfa = false) - { - var plugin = new StubIdentityProviderPlugin("stub", store, supportsMfa); - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(plugin); - var provider = services.BuildServiceProvider(); - - return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); - } - - private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password, string scope = "jobs:trigger") - { - var request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.Password, - Username = username, - Password = password, - ClientId = "cli-app", - Scope = scope - }; - - return new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = request - }; - } - - 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 - { - Issuer = new Uri("https://authority.test") - }; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; - - configure?.Invoke(options); - return options; - } - - private static AuthorityClientDocument CreateClientDocument(string allowedScopes = "jobs:trigger") - { - var document = new AuthorityClientDocument - { - ClientId = "cli-app", - ClientType = "public" - }; - - document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password"; - document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes; - document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha"; - - return document; - } - - private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin - { - public StubIdentityProviderPlugin(string name, IUserCredentialStore store, bool supportsMfa) - { - Name = name; - Type = "stub"; - var capabilities = supportsMfa - ? new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Mfa } - : new[] { AuthorityPluginCapabilities.Password }; - var manifest = new AuthorityPluginManifest( - Name: name, - Type: "stub", - Enabled: true, - AssemblyName: null, - AssemblyPath: null, - Capabilities: capabilities, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase), - ConfigPath: $"{name}.yaml"); - Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); - Credentials = store; - ClaimsEnricher = new NoopClaimsEnricher(); - Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false); - } - - public string Name { get; } - public string Type { get; } - public AuthorityPluginContext Context { get; } - public IUserCredentialStore Credentials { get; } - public IClaimsEnricher ClaimsEnricher { get; } - public IClientProvisioningStore? ClientProvisioning => null; - public AuthorityIdentityProviderCapabilities Capabilities { get; } - - public ValueTask CheckHealthAsync(CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); - } - - private sealed class NoopClaimsEnricher : IClaimsEnricher - { - public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) - => ValueTask.CompletedTask; - } - - private sealed class SuccessCredentialStore : IUserCredentialStore - { - public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) - { - var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false); - return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor)); - } - - public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) - => throw new NotImplementedException(); - - public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) - => ValueTask.FromResult(null); - } - - private sealed class FailureCredentialStore : IUserCredentialStore - { - public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, "Invalid username or password.")); - - public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) - => throw new NotImplementedException(); - - public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) - => ValueTask.FromResult(null); - } - - private sealed class LockoutCredentialStore : IUserCredentialStore - { - public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) - { - var retry = TimeSpan.FromMinutes(5); - var properties = new[] - { - new AuthEventProperty - { - Name = "plugin.lockout_until", - Value = ClassifiedString.Public(TimeProvider.System.GetUtcNow().Add(retry).ToString("O", CultureInfo.InvariantCulture)) - } - }; - - return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure( - AuthorityCredentialFailureCode.LockedOut, - "Account locked.", - retry, - properties)); - } - - public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) - => throw new NotImplementedException(); - - public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) - => ValueTask.FromResult(null); - } - - private sealed class StubClientStore : IAuthorityClientStore - { - private AuthorityClientDocument? document; - - public StubClientStore(AuthorityClientDocument document) - { - this.document = document ?? throw new ArgumentNullException(nameof(document)); - } - - public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - var result = document is not null && string.Equals(clientId, document.ClientId, StringComparison.Ordinal) - ? document - : null; - return ValueTask.FromResult(result); - } - - public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - this.document = document ?? throw new ArgumentNullException(nameof(document)); - return ValueTask.CompletedTask; - } - - public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (document is not null && string.Equals(clientId, document.ClientId, StringComparison.Ordinal)) - { - document = null; - return ValueTask.FromResult(true); - } - - return ValueTask.FromResult(false); - } - } - - private sealed class TestAuthEventSink : IAuthEventSink - { - public List Events { get; } = new(); - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - Events.Add(record); - return ValueTask.CompletedTask; - } - } - - private sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor - { - private AuthorityRateLimiterMetadata? metadata; - - public AuthorityRateLimiterMetadata? GetMetadata() => metadata; - - public void SetClientId(string? clientId) - { - metadata ??= new AuthorityRateLimiterMetadata(); - metadata.ClientId = clientId; - } - - public void SetSubjectId(string? subjectId) - { - metadata ??= new AuthorityRateLimiterMetadata(); - metadata.SubjectId = subjectId; - } - - public void SetTenant(string? tenant) - { - metadata ??= new AuthorityRateLimiterMetadata(); - metadata.Tenant = tenant; - } - - public void SetProject(string? project) - { - metadata ??= new AuthorityRateLimiterMetadata(); - metadata.Project = project; - } - - public void SetTag(string name, string? value) - { - metadata ??= new AuthorityRateLimiterMetadata(); - metadata.SetTag(name, value); - } - - public void Clear() - { - metadata = null; - } - } - -} +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using StellaOps.Authority.OpenIddict; +using StellaOps.Authority.OpenIddict.Handlers; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Authority.RateLimiting; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Cryptography.Audit; +using StellaOps.Configuration; +using StellaOps.Auth.Abstractions; +using Xunit; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public class PasswordGrantHandlersTests +{ + private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); + + [Fact] + public async Task HandlePasswordGrant_EmitsSuccessAuditEvent() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument()); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!"); + + await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); + + var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); + Assert.Equal("tenant-alpha", successEvent.Tenant.Value); + + var metadata = metadataAccessor.GetMetadata(); + Assert.Equal("tenant-alpha", metadata?.Tenant); + } + + [Fact] + public async Task HandlePasswordGrant_EmitsFailureAuditEvent() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new FailureCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument()); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "BadPassword!"); + + await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); + + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsAdvisoryReadWithoutAocVerify() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("advisory:read aoc:verify")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsObsIncidentWithoutReason() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("obs:incident")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Contains("incident_reason", context.ErrorDescription); + } + + [Fact] + public async Task HandlePasswordGrant_AddsIncidentReasonAndAuthTime() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument("obs:incident"); + var clientStore = new StubClientStore(clientDocument); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); + SetParameter(transaction, "incident_reason", "Sev1 drill activation"); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validate.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handle.HandleAsync(handleContext); + + Assert.False(handleContext.IsRejected); + var principal = Assert.IsType(handleContext.Principal); + Assert.Equal("Sev1 drill activation", principal.GetClaim(StellaOpsClaimTypes.IncidentReason)); + var authTimeClaim = principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime); + Assert.False(string.IsNullOrWhiteSpace(authTimeClaim)); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsAdvisoryAiViewWithoutAocVerify() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("advisory-ai:view aoc:verify")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/advisory-ai/vex read scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsAdvisoryAiScopeWithoutTenant() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument("advisory-ai:view"); + clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); + var clientStore = new StubClientStore(clientDocument); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Advisory AI scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AdvisoryAiView, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsSignalsScopeWithoutAocVerify() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("signals:write signals:read signals:admin aoc:verify")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "signals:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutReason() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); + SetParameter(transaction, "policy_ticket", "CR-1001"); + SetParameter(transaction, "policy_digest", new string('a', 64)); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Policy attestation actions require 'policy_reason'.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => + record.EventType == "authority.password.grant" && + record.Outcome == AuthEventOutcome.Failure && + record.Properties.Any(property => property.Name == "policy.action")); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutTicket() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); + 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); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Policy attestation actions require 'policy_ticket'.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutDigest() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); + SetParameter(transaction, "policy_reason", "Publish approved policy"); + SetParameter(transaction, "policy_ticket", "CR-1002"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Policy attestation actions require 'policy_digest'.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsPolicyPublishWithInvalidDigest() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); + 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); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("policy_digest must be a hexadecimal string between 32 and 128 characters.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Theory] + [InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)] + [InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] + public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation) + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument(scope); + var clientStore = new StubClientStore(clientDocument); + + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", scope); + 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); + Assert.False(validateContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handle.HandleAsync(handleContext); + + Assert.False(handleContext.IsRejected); + var principal = Assert.IsType(handleContext.Principal); + Assert.Equal(expectedOperation, principal.GetClaim(StellaOpsClaimTypes.PolicyOperation)); + Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest)); + Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason)); + Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket)); + Assert.Contains(sink.Events, record => + record.EventType == "authority.password.grant" && + record.Outcome == AuthEventOutcome.Success && + record.Properties.Any(property => property.Name == "policy.action")); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument("policy:author"); + clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); + var clientStore = new StubClientStore(clientDocument); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task ValidatePasswordGrant_AllowsPolicyAuthor() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("policy:author")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); + } + + [Fact] + public async Task HandlePasswordGrant_EmitsLockoutAuditEvent() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new LockoutCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument()); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Locked!"); + + await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); + + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.LockedOut); + } + + [Fact] + public async Task ValidatePasswordGrant_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument()); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!"); + SetParameter(transaction, "unexpected_param", "value"); + + await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + + var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); + Assert.Equal(AuthEventOutcome.Failure, tamperEvent.Outcome); + Assert.Contains(tamperEvent.Properties, property => + string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && + string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ValidatePasswordGrant_RejectsExceptionsApprove_WhenMfaRequiredAndProviderLacksSupport() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: false); + var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve")); + var authorityOptions = CreateAuthorityOptions(opts => + { + opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions + { + Id = "secops", + AuthorityRouteId = "approvals/secops", + RequireMfa = true + }); + }); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Exception approval scope requires an MFA-capable identity provider.", context.ErrorDescription); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); + } + + [Fact] + public async Task HandlePasswordGrant_AllowsExceptionsApprove_WhenMfaSupported() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: true); + var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve")); + var authorityOptions = CreateAuthorityOptions(opts => + { + opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions + { + Id = "secops", + AuthorityRouteId = "approvals/secops", + RequireMfa = true + }); + }); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validate.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handle.HandleAsync(handleContext); + + Assert.False(handleContext.IsRejected); + Assert.NotNull(handleContext.Principal); + Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); + } + + private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store, bool supportsMfa = false) + { + var plugin = new StubIdentityProviderPlugin("stub", store, supportsMfa); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(plugin); + var provider = services.BuildServiceProvider(); + + return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); + } + + private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password, string scope = "jobs:trigger") + { + var request = new OpenIddictRequest + { + GrantType = OpenIddictConstants.GrantTypes.Password, + Username = username, + Password = password, + ClientId = "cli-app", + Scope = scope + }; + + return new OpenIddictServerTransaction + { + EndpointType = OpenIddictServerEndpointType.Token, + Options = new OpenIddictServerOptions(), + Request = request + }; + } + + 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 + { + Issuer = new Uri("https://authority.test") + }; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; + + configure?.Invoke(options); + return options; + } + + private static AuthorityClientDocument CreateClientDocument(string allowedScopes = "jobs:trigger") + { + var document = new AuthorityClientDocument + { + ClientId = "cli-app", + ClientType = "public" + }; + + document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password"; + document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes; + document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha"; + + return document; + } + + private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin + { + public StubIdentityProviderPlugin(string name, IUserCredentialStore store, bool supportsMfa) + { + Name = name; + Type = "stub"; + var capabilities = supportsMfa + ? new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Mfa } + : new[] { AuthorityPluginCapabilities.Password }; + var manifest = new AuthorityPluginManifest( + Name: name, + Type: "stub", + Enabled: true, + AssemblyName: null, + AssemblyPath: null, + Capabilities: capabilities, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase), + ConfigPath: $"{name}.yaml"); + Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); + Credentials = store; + ClaimsEnricher = new NoopClaimsEnricher(); + Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false); + } + + public string Name { get; } + public string Type { get; } + public AuthorityPluginContext Context { get; } + public IUserCredentialStore Credentials { get; } + public IClaimsEnricher ClaimsEnricher { get; } + public IClientProvisioningStore? ClientProvisioning => null; + public AuthorityIdentityProviderCapabilities Capabilities { get; } + + public ValueTask CheckHealthAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); + } + + private sealed class NoopClaimsEnricher : IClaimsEnricher + { + public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + } + + private sealed class SuccessCredentialStore : IUserCredentialStore + { + public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) + { + var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false); + return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor)); + } + + public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) + => ValueTask.FromResult(null); + } + + private sealed class FailureCredentialStore : IUserCredentialStore + { + public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, "Invalid username or password.")); + + public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) + => ValueTask.FromResult(null); + } + + private sealed class LockoutCredentialStore : IUserCredentialStore + { + public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) + { + var retry = TimeSpan.FromMinutes(5); + var properties = new[] + { + new AuthEventProperty + { + Name = "plugin.lockout_until", + Value = ClassifiedString.Public(TimeProvider.System.GetUtcNow().Add(retry).ToString("O", CultureInfo.InvariantCulture)) + } + }; + + return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure( + AuthorityCredentialFailureCode.LockedOut, + "Account locked.", + retry, + properties)); + } + + public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) + => ValueTask.FromResult(null); + } + + private sealed class StubClientStore : IAuthorityClientStore + { + private AuthorityClientDocument? document; + + public StubClientStore(AuthorityClientDocument document) + { + this.document = document ?? throw new ArgumentNullException(nameof(document)); + } + + public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var result = document is not null && string.Equals(clientId, document.ClientId, StringComparison.Ordinal) + ? document + : null; + return ValueTask.FromResult(result); + } + + public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + this.document = document ?? throw new ArgumentNullException(nameof(document)); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (document is not null && string.Equals(clientId, document.ClientId, StringComparison.Ordinal)) + { + document = null; + return ValueTask.FromResult(true); + } + + return ValueTask.FromResult(false); + } + } + + private sealed class TestAuthEventSink : IAuthEventSink + { + public List Events { get; } = new(); + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + Events.Add(record); + return ValueTask.CompletedTask; + } + } + + private sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor + { + private AuthorityRateLimiterMetadata? metadata; + + public AuthorityRateLimiterMetadata? GetMetadata() => metadata; + + public void SetClientId(string? clientId) + { + metadata ??= new AuthorityRateLimiterMetadata(); + metadata.ClientId = clientId; + } + + public void SetSubjectId(string? subjectId) + { + metadata ??= new AuthorityRateLimiterMetadata(); + metadata.SubjectId = subjectId; + } + + public void SetTenant(string? tenant) + { + metadata ??= new AuthorityRateLimiterMetadata(); + metadata.Tenant = tenant; + } + + public void SetProject(string? project) + { + metadata ??= new AuthorityRateLimiterMetadata(); + metadata.Project = project; + } + + public void SetTag(string name, string? value) + { + metadata ??= new AuthorityRateLimiterMetadata(); + metadata.SetTag(name, value); + } + + public void Clear() + { + metadata = null; + } + } + +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj index 51773b21..cde0ee33 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj @@ -1,21 +1,21 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - + + + + net10.0 + enable + enable + false + + + + + + + + + + - - + + 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 769c0237..71015c99 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Vulnerability/VulnWorkflowTokenEndpointTests.cs @@ -1,457 +1,457 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Time.Testing; -using StellaOps.Auth.Abstractions; -using StellaOps.Authority; -using StellaOps.Authority.Tests.Infrastructure; -using StellaOps.Authority.Vulnerability.Attachments; -using StellaOps.Authority.Vulnerability.Workflow; -using StellaOps.Configuration; -using StellaOps.Cryptography; -using StellaOps.Cryptography.Audit; -using Xunit; - -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) - { - this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); - } - - [Fact] - public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits() - { - var tempDir = Directory.CreateTempSubdirectory("workflow-token-success"); - var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); - - try - { - CreateEcPrivateKey(keyPath); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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")); - - using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var issuePayload = new - { - tenant = "tenant-default", - actions = new[] { "assign", "comment" }, - context = new Dictionary { ["finding_id"] = "F-123" }, - nonce = "workflow-nonce-123456", - expiresInSeconds = 600 - }; - - var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); - var issueBody = await issueResponse.Content.ReadAsStringAsync(); - Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}"); - - var issued = System.Text.Json.JsonSerializer.Deserialize( - issueBody, - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - Assert.NotNull(issued); - Assert.Equal("workflow-nonce-123456", issued!.Nonce); - Assert.Contains("assign", issued.Actions); - Assert.Contains("comment", issued.Actions); - - var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest - { - Token = issued.Token, - RequiredAction = "assign", - Tenant = "tenant-default", - Nonce = "workflow-nonce-123456" - }; - - var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload); - var verifyBody = await verifyResponse.Content.ReadAsStringAsync(); - Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}"); - - var verified = System.Text.Json.JsonSerializer.Deserialize( - verifyBody, - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - Assert.NotNull(verified); - Assert.Equal("tenant-default", verified!.Tenant); - Assert.Equal("workflow-nonce-123456", verified.Nonce); - - 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, evt => evt.EventType == "vuln.workflow.csrf.verified"); - Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456"); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - [Fact] - public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing() - { - var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions"); - var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); - - try - { - CreateEcPrivateKey(keyPath); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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")); - - using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var issuePayload = new - { - tenant = "tenant-default", - actions = Array.Empty() - }; - - var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var error = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(error); - Assert.Equal("invalid_request", error!["error"]); - Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase); - - Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - [Fact] - public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted() - { - var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action"); - var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); - - try - { - CreateEcPrivateKey(keyPath); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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")); - - using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var issuePayload = new - { - tenant = "tenant-default", - actions = new[] { "assign" }, - nonce = "workflow-nonce-789012" - }; - - var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); - Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); - var issued = await issueResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(issued); - - var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest - { - Token = issued!.Token, - RequiredAction = "close", - Tenant = "tenant-default", - Nonce = "workflow-nonce-789012" - }; - - var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload); - Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode); - - var error = await verifyResponse.Content.ReadFromJsonAsync>(); - Assert.NotNull(error); - Assert.Equal("invalid_token", error!["error"]); - Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal); - - Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); - Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified"); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - [Fact] - public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits() - { - var tempDir = Directory.CreateTempSubdirectory("attachment-token-success"); - var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem"); - - try - { - CreateEcPrivateKey(keyPath); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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")); - - using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath); - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var issuePayload = new VulnAttachmentTokenIssueRequest - { - Tenant = "tenant-default", - LedgerEventHash = "ledger-hash-001", - AttachmentId = "attach-123", - FindingId = "find-456", - ContentHash = "sha256:abc123", - ContentType = "application/pdf", - Metadata = new Dictionary { ["origin"] = "vuln-workflow" } - }; - - var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload); - Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); - var issued = await issueResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(issued); - Assert.Equal("attach-123", issued!.AttachmentId); - - var verifyPayload = new VulnAttachmentTokenVerifyRequest - { - Token = issued.Token, - Tenant = "tenant-default", - LedgerEventHash = "ledger-hash-001", - AttachmentId = "attach-123" - }; - - var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload); - Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode); - var verified = await verifyResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(verified); - Assert.Equal("ledger-hash-001", verified!.LedgerEventHash); - - 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, 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 - { - TryDeleteDirectory(tempDir.FullName); - } - } - - [Fact] - public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch() - { - var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch"); - var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem"); - - try - { - CreateEcPrivateKey(keyPath); - - using var env = new EnvironmentVariableScope(new[] - { - new KeyValuePair(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")); - - using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath); - using var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); - client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate); - client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); - client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); - - var issuePayload = new VulnAttachmentTokenIssueRequest - { - Tenant = "tenant-default", - LedgerEventHash = "ledger-hash-001", - AttachmentId = "attach-123" - }; - - var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload); - Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); - var issued = await issueResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(issued); - - var verifyPayload = new VulnAttachmentTokenVerifyRequest - { - Token = issued!.Token, - Tenant = "tenant-default", - LedgerEventHash = "ledger-hash-999", - AttachmentId = "attach-123" - }; - - var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload); - Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode); - - var error = await verifyResponse.Content.ReadFromJsonAsync>(); - Assert.NotNull(error); - Assert.Equal("invalid_token", error!["error"]); - Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase); - - Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued"); - Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified"); - } - finally - { - TryDeleteDirectory(tempDir.FullName); - } - } - - private WebApplicationFactory CreateSignedAuthorityApp( - RecordingAuthEventSink sink, - FakeTimeProvider timeProvider, - string signingKeyId, - string signingKeyPath) - { - return factory.WithWebHostBuilder(host => - { - host.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Signing:Enabled"] = "true", - ["Authority:Signing:ActiveKeyId"] = signingKeyId, - ["Authority:Signing:KeyPath"] = signingKeyPath, - ["Authority:Signing:KeySource"] = "file", - ["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256 - }); - }); - - host.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(sink); - services.Replace(ServiceDescriptor.Singleton(timeProvider)); - services.PostConfigure(options => - { - options.Signing.Enabled = true; - options.Signing.ActiveKeyId = signingKeyId; - options.Signing.KeyPath = signingKeyPath; - options.Signing.KeySource = "file"; - options.Signing.Algorithm = SignatureAlgorithms.Es256; - options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true; - options.VulnerabilityExplorer.Attachments.Enabled = true; - }); - - var authBuilder = services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; - options.DefaultChallengeScheme = TestAuthHandler.SchemeName; - }); - - authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); - authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); - }); - }); - } - - private static void CreateEcPrivateKey(string path) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem()); - } - - private static void TryDeleteDirectory(string directory) - { - try - { - Directory.Delete(directory, recursive: true); - } - catch - { - // Ignored during cleanup. - } - } - - private sealed class RecordingAuthEventSink : IAuthEventSink - { - private readonly List events = new(); - - public IReadOnlyList Events => events; - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - events.Add(record); - return ValueTask.CompletedTask; - } - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Auth.Abstractions; +using StellaOps.Authority; +using StellaOps.Authority.Tests.Infrastructure; +using StellaOps.Authority.Vulnerability.Attachments; +using StellaOps.Authority.Vulnerability.Workflow; +using StellaOps.Configuration; +using StellaOps.Cryptography; +using StellaOps.Cryptography.Audit; +using Xunit; + +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) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + [Fact] + public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits() + { + var tempDir = Directory.CreateTempSubdirectory("workflow-token-success"); + var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); + + try + { + CreateEcPrivateKey(keyPath); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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")); + + using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var issuePayload = new + { + tenant = "tenant-default", + actions = new[] { "assign", "comment" }, + context = new Dictionary { ["finding_id"] = "F-123" }, + nonce = "workflow-nonce-123456", + expiresInSeconds = 600 + }; + + var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); + var issueBody = await issueResponse.Content.ReadAsStringAsync(); + Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}"); + + var issued = System.Text.Json.JsonSerializer.Deserialize( + issueBody, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(issued); + Assert.Equal("workflow-nonce-123456", issued!.Nonce); + Assert.Contains("assign", issued.Actions); + Assert.Contains("comment", issued.Actions); + + var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest + { + Token = issued.Token, + RequiredAction = "assign", + Tenant = "tenant-default", + Nonce = "workflow-nonce-123456" + }; + + var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload); + var verifyBody = await verifyResponse.Content.ReadAsStringAsync(); + Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}"); + + var verified = System.Text.Json.JsonSerializer.Deserialize( + verifyBody, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(verified); + Assert.Equal("tenant-default", verified!.Tenant); + Assert.Equal("workflow-nonce-123456", verified.Nonce); + + 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, evt => evt.EventType == "vuln.workflow.csrf.verified"); + Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456"); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + [Fact] + public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing() + { + var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions"); + var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); + + try + { + CreateEcPrivateKey(keyPath); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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")); + + using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var issuePayload = new + { + tenant = "tenant-default", + actions = Array.Empty() + }; + + var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var error = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(error); + Assert.Equal("invalid_request", error!["error"]); + Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase); + + Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + [Fact] + public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted() + { + var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action"); + var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem"); + + try + { + CreateEcPrivateKey(keyPath); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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")); + + using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath); + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var issuePayload = new + { + tenant = "tenant-default", + actions = new[] { "assign" }, + nonce = "workflow-nonce-789012" + }; + + var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload); + Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); + var issued = await issueResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(issued); + + var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest + { + Token = issued!.Token, + RequiredAction = "close", + Tenant = "tenant-default", + Nonce = "workflow-nonce-789012" + }; + + var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload); + Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode); + + var error = await verifyResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(error); + Assert.Equal("invalid_token", error!["error"]); + Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal); + + Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued"); + Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified"); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + [Fact] + public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits() + { + var tempDir = Directory.CreateTempSubdirectory("attachment-token-success"); + var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem"); + + try + { + CreateEcPrivateKey(keyPath); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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")); + + using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath); + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var issuePayload = new VulnAttachmentTokenIssueRequest + { + Tenant = "tenant-default", + LedgerEventHash = "ledger-hash-001", + AttachmentId = "attach-123", + FindingId = "find-456", + ContentHash = "sha256:abc123", + ContentType = "application/pdf", + Metadata = new Dictionary { ["origin"] = "vuln-workflow" } + }; + + var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload); + Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); + var issued = await issueResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(issued); + Assert.Equal("attach-123", issued!.AttachmentId); + + var verifyPayload = new VulnAttachmentTokenVerifyRequest + { + Token = issued.Token, + Tenant = "tenant-default", + LedgerEventHash = "ledger-hash-001", + AttachmentId = "attach-123" + }; + + var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload); + Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode); + var verified = await verifyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(verified); + Assert.Equal("ledger-hash-001", verified!.LedgerEventHash); + + 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, 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 + { + TryDeleteDirectory(tempDir.FullName); + } + } + + [Fact] + public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch() + { + var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch"); + var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem"); + + try + { + CreateEcPrivateKey(keyPath); + + using var env = new EnvironmentVariableScope(new[] + { + new KeyValuePair(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")); + + using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath); + using var client = app.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName); + client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate); + client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default"); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var issuePayload = new VulnAttachmentTokenIssueRequest + { + Tenant = "tenant-default", + LedgerEventHash = "ledger-hash-001", + AttachmentId = "attach-123" + }; + + var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload); + Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); + var issued = await issueResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(issued); + + var verifyPayload = new VulnAttachmentTokenVerifyRequest + { + Token = issued!.Token, + Tenant = "tenant-default", + LedgerEventHash = "ledger-hash-999", + AttachmentId = "attach-123" + }; + + var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload); + Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode); + + var error = await verifyResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(error); + Assert.Equal("invalid_token", error!["error"]); + Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase); + + Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued"); + Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified"); + } + finally + { + TryDeleteDirectory(tempDir.FullName); + } + } + + private WebApplicationFactory CreateSignedAuthorityApp( + RecordingAuthEventSink sink, + FakeTimeProvider timeProvider, + string signingKeyId, + string signingKeyPath) + { + return factory.WithWebHostBuilder(host => + { + host.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Signing:Enabled"] = "true", + ["Authority:Signing:ActiveKeyId"] = signingKeyId, + ["Authority:Signing:KeyPath"] = signingKeyPath, + ["Authority:Signing:KeySource"] = "file", + ["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256 + }); + }); + + host.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(sink); + services.Replace(ServiceDescriptor.Singleton(timeProvider)); + services.PostConfigure(options => + { + options.Signing.Enabled = true; + options.Signing.ActiveKeyId = signingKeyId; + options.Signing.KeyPath = signingKeyPath; + options.Signing.KeySource = "file"; + options.Signing.Algorithm = SignatureAlgorithms.Es256; + options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true; + options.VulnerabilityExplorer.Attachments.Enabled = true; + }); + + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthHandler.SchemeName; + }); + + authBuilder.AddScheme(TestAuthHandler.SchemeName, _ => { }); + authBuilder.AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); + }); + }); + } + + private static void CreateEcPrivateKey(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem()); + } + + private static void TryDeleteDirectory(string directory) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch + { + // Ignored during cleanup. + } + } + + private sealed class RecordingAuthEventSink : IAuthEventSink + { + private readonly List events = new(); + + public IReadOnlyList Events => events; + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + events.Add(record); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/LegacyAuthDeprecationMiddleware.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/LegacyAuthDeprecationMiddleware.cs index 5f5886b2..4e2a6004 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/LegacyAuthDeprecationMiddleware.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/LegacyAuthDeprecationMiddleware.cs @@ -1,254 +1,254 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -using StellaOps.Configuration; -using StellaOps.Cryptography.Audit; - -namespace StellaOps.Authority; - -internal sealed class LegacyAuthDeprecationMiddleware -{ - private const string LegacyEventType = "authority.api.legacy_endpoint"; - private const string SunsetHeaderName = "Sunset"; - - private static readonly IReadOnlyDictionary LegacyEndpointMap = - new Dictionary(PathStringComparer.Instance) - { - [new PathString("/oauth/token")] = new PathString("/token"), - [new PathString("/oauth/introspect")] = new PathString("/introspect"), - [new PathString("/oauth/revoke")] = new PathString("/revoke") - }; - - private readonly RequestDelegate next; - private readonly AuthorityLegacyAuthEndpointOptions options; - private readonly IAuthEventSink auditSink; - private readonly TimeProvider clock; - private readonly ILogger logger; - - public LegacyAuthDeprecationMiddleware( - RequestDelegate next, - IOptions authorityOptions, - IAuthEventSink auditSink, - TimeProvider clock, - ILogger logger) - { - this.next = next ?? throw new ArgumentNullException(nameof(next)); - if (authorityOptions is null) - { - throw new ArgumentNullException(nameof(authorityOptions)); - } - - options = authorityOptions.Value.ApiLifecycle.LegacyAuth ?? - throw new InvalidOperationException("Authority legacy auth endpoint options are not configured."); - this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); - this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task InvokeAsync(HttpContext context) - { - ArgumentNullException.ThrowIfNull(context); - - if (!options.Enabled) - { - await next(context).ConfigureAwait(false); - return; - } - - if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath)) - { - await next(context).ConfigureAwait(false); - return; - } - - var originalPath = context.Request.Path; - context.Request.Path = canonicalPath; - - logger.LogInformation( - "Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.", - originalPath, - canonicalPath); - - AppendDeprecationHeaders(context.Response); - - await next(context).ConfigureAwait(false); - - await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false); - } - - private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath) - { - if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath)) - { - return true; - } - - canonicalPath = PathString.Empty; - return false; - } - - private static PathString Normalize(PathString value) - { - if (!value.HasValue) - { - return PathString.Empty; - } - - var trimmed = value.Value!.TrimEnd('/'); - return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant()); - } - - private void AppendDeprecationHeaders(HttpResponse response) - { - if (response.HasStarted) - { - return; - } - - var deprecation = FormatHttpDate(options.DeprecationDate); - response.Headers["Deprecation"] = deprecation; - - var sunset = FormatHttpDate(options.SunsetDate); - response.Headers[SunsetHeaderName] = sunset; - - if (!string.IsNullOrWhiteSpace(options.DocumentationUrl)) - { - var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\""; - response.Headers.Append(HeaderNames.Link, linkValue); - } - - var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\""; - response.Headers[HeaderNames.Warning] = warning; - } - - private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath) - { - try - { - var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; - - var network = BuildNetwork(context); - - var record = new AuthEventRecord - { - EventType = LegacyEventType, - OccurredAt = clock.GetUtcNow(), - CorrelationId = correlation, - Outcome = AuthEventOutcome.Success, - Reason = null, - Subject = null, - Client = null, - Tenant = ClassifiedString.Empty, - Project = ClassifiedString.Empty, - Scopes = Array.Empty(), - Network = network, - Properties = BuildProperties( - ("legacy.endpoint.original", originalPath.Value), - ("legacy.endpoint.canonical", canonicalPath.Value), - ("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)), - ("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)), - ("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture))) - }; - - await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event."); - } - } - - private static AuthEventNetwork? BuildNetwork(HttpContext context) - { - var remote = context.Connection.RemoteIpAddress?.ToString(); - var forwarded = context.Request.Headers["X-Forwarded-For"].ToString(); - var userAgent = context.Request.Headers.UserAgent.ToString(); - - if (string.IsNullOrWhiteSpace(remote) && - string.IsNullOrWhiteSpace(forwarded) && - string.IsNullOrWhiteSpace(userAgent)) - { - return null; - } - - return new AuthEventNetwork - { - RemoteAddress = ClassifiedString.Personal(Normalize(remote)), - ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)), - UserAgent = ClassifiedString.Personal(Normalize(userAgent)) - }; - } - - private static string? Normalize(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var trimmed = value.Trim(); - return trimmed.Length == 0 ? null : trimmed; - } - - private static IReadOnlyList BuildProperties(params (string Name, string? Value)[] entries) - { - if (entries.Length == 0) - { - return Array.Empty(); - } - - var list = new List(entries.Length); - foreach (var (name, value) in entries) - { - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - - list.Add(new AuthEventProperty - { - Name = name, - Value = string.IsNullOrWhiteSpace(value) - ? ClassifiedString.Empty - : ClassifiedString.Public(value) - }); - } - - return list.Count == 0 ? Array.Empty() : list; - } - - private static string FormatHttpDate(DateTimeOffset value) - { - return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture); - } - - private sealed class PathStringComparer : IEqualityComparer - { - public static readonly PathStringComparer Instance = new(); - - public bool Equals(PathString x, PathString y) - { - return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal); - } - - public int GetHashCode(PathString obj) - { - return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0; - } - } -} - -internal static class LegacyAuthDeprecationExtensions -{ - public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app) - { - ArgumentNullException.ThrowIfNull(app); - return app.UseMiddleware(); - } -} +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using StellaOps.Configuration; +using StellaOps.Cryptography.Audit; + +namespace StellaOps.Authority; + +internal sealed class LegacyAuthDeprecationMiddleware +{ + private const string LegacyEventType = "authority.api.legacy_endpoint"; + private const string SunsetHeaderName = "Sunset"; + + private static readonly IReadOnlyDictionary LegacyEndpointMap = + new Dictionary(PathStringComparer.Instance) + { + [new PathString("/oauth/token")] = new PathString("/token"), + [new PathString("/oauth/introspect")] = new PathString("/introspect"), + [new PathString("/oauth/revoke")] = new PathString("/revoke") + }; + + private readonly RequestDelegate next; + private readonly AuthorityLegacyAuthEndpointOptions options; + private readonly IAuthEventSink auditSink; + private readonly TimeProvider clock; + private readonly ILogger logger; + + public LegacyAuthDeprecationMiddleware( + RequestDelegate next, + IOptions authorityOptions, + IAuthEventSink auditSink, + TimeProvider clock, + ILogger logger) + { + this.next = next ?? throw new ArgumentNullException(nameof(next)); + if (authorityOptions is null) + { + throw new ArgumentNullException(nameof(authorityOptions)); + } + + options = authorityOptions.Value.ApiLifecycle.LegacyAuth ?? + throw new InvalidOperationException("Authority legacy auth endpoint options are not configured."); + this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); + this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!options.Enabled) + { + await next(context).ConfigureAwait(false); + return; + } + + if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath)) + { + await next(context).ConfigureAwait(false); + return; + } + + var originalPath = context.Request.Path; + context.Request.Path = canonicalPath; + + logger.LogInformation( + "Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.", + originalPath, + canonicalPath); + + AppendDeprecationHeaders(context.Response); + + await next(context).ConfigureAwait(false); + + await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false); + } + + private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath) + { + if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath)) + { + return true; + } + + canonicalPath = PathString.Empty; + return false; + } + + private static PathString Normalize(PathString value) + { + if (!value.HasValue) + { + return PathString.Empty; + } + + var trimmed = value.Value!.TrimEnd('/'); + return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant()); + } + + private void AppendDeprecationHeaders(HttpResponse response) + { + if (response.HasStarted) + { + return; + } + + var deprecation = FormatHttpDate(options.DeprecationDate); + response.Headers["Deprecation"] = deprecation; + + var sunset = FormatHttpDate(options.SunsetDate); + response.Headers[SunsetHeaderName] = sunset; + + if (!string.IsNullOrWhiteSpace(options.DocumentationUrl)) + { + var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\""; + response.Headers.Append(HeaderNames.Link, linkValue); + } + + var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\""; + response.Headers[HeaderNames.Warning] = warning; + } + + private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath) + { + try + { + var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; + + var network = BuildNetwork(context); + + var record = new AuthEventRecord + { + EventType = LegacyEventType, + OccurredAt = clock.GetUtcNow(), + CorrelationId = correlation, + Outcome = AuthEventOutcome.Success, + Reason = null, + Subject = null, + Client = null, + Tenant = ClassifiedString.Empty, + Project = ClassifiedString.Empty, + Scopes = Array.Empty(), + Network = network, + Properties = BuildProperties( + ("legacy.endpoint.original", originalPath.Value), + ("legacy.endpoint.canonical", canonicalPath.Value), + ("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)), + ("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)), + ("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture))) + }; + + await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event."); + } + } + + private static AuthEventNetwork? BuildNetwork(HttpContext context) + { + var remote = context.Connection.RemoteIpAddress?.ToString(); + var forwarded = context.Request.Headers["X-Forwarded-For"].ToString(); + var userAgent = context.Request.Headers.UserAgent.ToString(); + + if (string.IsNullOrWhiteSpace(remote) && + string.IsNullOrWhiteSpace(forwarded) && + string.IsNullOrWhiteSpace(userAgent)) + { + return null; + } + + return new AuthEventNetwork + { + RemoteAddress = ClassifiedString.Personal(Normalize(remote)), + ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)), + UserAgent = ClassifiedString.Personal(Normalize(userAgent)) + }; + } + + private static string? Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + + private static IReadOnlyList BuildProperties(params (string Name, string? Value)[] entries) + { + if (entries.Length == 0) + { + return Array.Empty(); + } + + var list = new List(entries.Length); + foreach (var (name, value) in entries) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + list.Add(new AuthEventProperty + { + Name = name, + Value = string.IsNullOrWhiteSpace(value) + ? ClassifiedString.Empty + : ClassifiedString.Public(value) + }); + } + + return list.Count == 0 ? Array.Empty() : list; + } + + private static string FormatHttpDate(DateTimeOffset value) + { + return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture); + } + + private sealed class PathStringComparer : IEqualityComparer + { + public static readonly PathStringComparer Instance = new(); + + public bool Equals(PathString x, PathString y) + { + return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal); + } + + public int GetHashCode(PathString obj) + { + return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0; + } + } +} + +internal static class LegacyAuthDeprecationExtensions +{ + public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseMiddleware(); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs index 7b846b07..6bcb6487 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs @@ -1,181 +1,181 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.Configuration; -using StellaOps.Cryptography; - -namespace StellaOps.Authority.Signing; - -internal sealed class AuthorityJwksService -{ - private const string CacheKey = "authority:jwks:current"; - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly ICryptoProviderRegistry registry; - private readonly ILogger logger; - private readonly IMemoryCache cache; - private readonly TimeProvider timeProvider; - private readonly StellaOpsAuthorityOptions authorityOptions; - - public AuthorityJwksService( - ICryptoProviderRegistry registry, - ILogger logger, - IMemoryCache cache, - TimeProvider timeProvider, - IOptions authorityOptions) - { - this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); - this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - if (authorityOptions is null) - { - throw new ArgumentNullException(nameof(authorityOptions)); - } - - this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); - } - - public AuthorityJwksResult Get() - { - if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) && - cached is not null && - cached.ExpiresAt > timeProvider.GetUtcNow()) - { - return cached.Result; - } - - var response = new AuthorityJwksResponse(BuildKeys()); - var signingOptions = authorityOptions.Signing; - var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero - ? signingOptions.JwksCacheLifetime - : TimeSpan.FromMinutes(5); - var expires = timeProvider.GetUtcNow().Add(lifetime); - var etag = ComputeEtag(response, expires); - var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}"; - - var result = new AuthorityJwksResult(response, etag, expires, cacheControl); - var entry = new AuthorityJwksCacheEntry(result, expires); - - cache.Set(CacheKey, entry, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = lifetime - }); - return result; - } - - public void Invalidate() - { - cache.Remove(CacheKey); - } - - private IReadOnlyCollection BuildKeys() - { - var keys = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var provider in registry.Providers) - { - foreach (var signingKey in provider.GetSigningKeys()) - { - var keyId = signingKey.Reference.KeyId; - if (!seen.Add(keyId)) - { - continue; - } - - try - { - var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference); - var jwk = signer.ExportPublicJsonWebKey(); - var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse) - ? metadataUse - : jwk.Use; - - if (string.IsNullOrWhiteSpace(keyUse)) - { - keyUse = "sig"; - } - - var entry = new JwksKeyEntry - { - Kid = jwk.Kid, - Kty = jwk.Kty, - Use = keyUse, - Alg = jwk.Alg, - Crv = jwk.Crv, - X = jwk.X, - Y = jwk.Y, - Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active" - }; - keys.Add(entry); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId); - } - } - } - - keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal)); - return keys; - } - - private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt) - { - var payload = JsonSerializer.Serialize(response, SerializerOptions); - var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - var hash = SHA256.HashData(buffer); - return $"\"{Convert.ToHexString(hash)}\""; - } - - private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt); -} - -internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection Keys); - -internal sealed record AuthorityJwksResult( - AuthorityJwksResponse Response, - string ETag, - DateTimeOffset ExpiresAt, - string CacheControl); - -internal sealed class JwksKeyEntry -{ - [JsonPropertyName("kty")] - public string? Kty { get; set; } - - [JsonPropertyName("use")] - public string? Use { get; set; } - - [JsonPropertyName("kid")] - public string? Kid { get; set; } - - [JsonPropertyName("alg")] - public string? Alg { get; set; } - - [JsonPropertyName("crv")] - public string? Crv { get; set; } - - [JsonPropertyName("x")] - public string? X { get; set; } - - [JsonPropertyName("y")] - public string? Y { get; set; } - - [JsonPropertyName("status")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Status { get; set; } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Configuration; +using StellaOps.Cryptography; + +namespace StellaOps.Authority.Signing; + +internal sealed class AuthorityJwksService +{ + private const string CacheKey = "authority:jwks:current"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ICryptoProviderRegistry registry; + private readonly ILogger logger; + private readonly IMemoryCache cache; + private readonly TimeProvider timeProvider; + private readonly StellaOpsAuthorityOptions authorityOptions; + + public AuthorityJwksService( + ICryptoProviderRegistry registry, + ILogger logger, + IMemoryCache cache, + TimeProvider timeProvider, + IOptions authorityOptions) + { + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); + this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + if (authorityOptions is null) + { + throw new ArgumentNullException(nameof(authorityOptions)); + } + + this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); + } + + public AuthorityJwksResult Get() + { + if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) && + cached is not null && + cached.ExpiresAt > timeProvider.GetUtcNow()) + { + return cached.Result; + } + + var response = new AuthorityJwksResponse(BuildKeys()); + var signingOptions = authorityOptions.Signing; + var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero + ? signingOptions.JwksCacheLifetime + : TimeSpan.FromMinutes(5); + var expires = timeProvider.GetUtcNow().Add(lifetime); + var etag = ComputeEtag(response, expires); + var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}"; + + var result = new AuthorityJwksResult(response, etag, expires, cacheControl); + var entry = new AuthorityJwksCacheEntry(result, expires); + + cache.Set(CacheKey, entry, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = lifetime + }); + return result; + } + + public void Invalidate() + { + cache.Remove(CacheKey); + } + + private IReadOnlyCollection BuildKeys() + { + var keys = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var provider in registry.Providers) + { + foreach (var signingKey in provider.GetSigningKeys()) + { + var keyId = signingKey.Reference.KeyId; + if (!seen.Add(keyId)) + { + continue; + } + + try + { + var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference); + var jwk = signer.ExportPublicJsonWebKey(); + var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse) + ? metadataUse + : jwk.Use; + + if (string.IsNullOrWhiteSpace(keyUse)) + { + keyUse = "sig"; + } + + var entry = new JwksKeyEntry + { + Kid = jwk.Kid, + Kty = jwk.Kty, + Use = keyUse, + Alg = jwk.Alg, + Crv = jwk.Crv, + X = jwk.X, + Y = jwk.Y, + Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active" + }; + keys.Add(entry); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId); + } + } + } + + keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal)); + return keys; + } + + private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt) + { + var payload = JsonSerializer.Serialize(response, SerializerOptions); + var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + var hash = SHA256.HashData(buffer); + return $"\"{Convert.ToHexString(hash)}\""; + } + + private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt); +} + +internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection Keys); + +internal sealed record AuthorityJwksResult( + AuthorityJwksResponse Response, + string ETag, + DateTimeOffset ExpiresAt, + string CacheControl); + +internal sealed class JwksKeyEntry +{ + [JsonPropertyName("kty")] + public string? Kty { get; set; } + + [JsonPropertyName("use")] + public string? Use { get; set; } + + [JsonPropertyName("kid")] + public string? Kid { get; set; } + + [JsonPropertyName("alg")] + public string? Alg { get; set; } + + [JsonPropertyName("crv")] + public string? Crv { get; set; } + + [JsonPropertyName("x")] + public string? X { get; set; } + + [JsonPropertyName("y")] + public string? Y { get; set; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; set; } +} diff --git a/src/Authority/StellaOps.Authority/TASKS.md b/src/Authority/StellaOps.Authority/TASKS.md index ec95a7ce..e1eda172 100644 --- a/src/Authority/StellaOps.Authority/TASKS.md +++ b/src/Authority/StellaOps.Authority/TASKS.md @@ -1,170 +1,170 @@ -# Authority Host Task Board — Epic 1: Aggregation-Only Contract -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003. -> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests. -> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet`), preventing Authority test suite run; waiting on Concelier fix before rerun. -> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates). -> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients. -> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles. - -## Link-Not-Merge v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation. - -## Policy Engine v2 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass). -> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example. - -## Graph Explorer v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| - -## Policy Engine + Editor v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. | -> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands. -| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. | -> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour. -> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions. - -## Graph & Vuln Explorer v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged. - -## Orchestrator Dashboard - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first. -> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements. -> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions. -> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break. -> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass. -| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. | -> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`. - -## StellaOps Console (Sprint 23) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added. -> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases. -> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task. -> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published. -> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged. - -## Policy Studio (Sprint 27) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue. -| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. | -> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work. -> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references. -| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. | -> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement. - -## Exceptions v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples. -> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates. - -## Reachability v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating. - -## Vulnerability Explorer (Sprint 29) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | 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. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. | -| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. | -| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. | -> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes. -> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours. - -## Advisory AI (Sprint 31) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. | -| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. | - -## Export Center -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| - -## Notifications Studio -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. | -| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. | -> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement. -| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. | -> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`. - - -## CLI Parity & Task Packs -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. | -> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows. -> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001). -> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`. -| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. | -> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published. - -## Authority-Backed Scopes & Tenancy (Epic 14) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter. -| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. | -> 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. -> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows. - -## Observability & Forensics (Epic 15) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. | -| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. | -| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. | - -## Air-Gapped Mode (Epic 16) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. | -| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. | -> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`. -> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests). -| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. | -> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild). - -## SDKs & OpenAPI (Epic 17) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs. -> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions. -| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. | -> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour. -| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. | -> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild). +# Authority Host Task Board — Epic 1: Aggregation-Only Contract +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003. +> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests. +> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet`), preventing Authority test suite run; waiting on Concelier fix before rerun. +> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates). +> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients. +> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles. + +## Link-Not-Merge v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation. + +## Policy Engine v2 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass). +> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example. + +## Graph Explorer v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| + +## Policy Engine + Editor v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. | +> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands. +| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. | +> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour. +> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions. + +## Graph & Vuln Explorer v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged. + +## Orchestrator Dashboard + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first. +> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements. +> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions. +> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break. +> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass. +| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. | +> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`. + +## StellaOps Console (Sprint 23) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added. +> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases. +> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task. +> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published. +> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged. + +## Policy Studio (Sprint 27) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue. +| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. | +> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work. +> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references. +| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. | +> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement. + +## Exceptions v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples. +> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates. + +## Reachability v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating. + +## Vulnerability Explorer (Sprint 29) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | 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. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. | +| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. | +| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. | +> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes. +> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours. + +## Advisory AI (Sprint 31) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. | +| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. | + +## Export Center +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| + +## Notifications Studio +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. | +| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. | +> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement. +| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. | +> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`. + + +## CLI Parity & Task Packs +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. | +> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows. +> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001). +> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`. +| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. | +> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published. + +## Authority-Backed Scopes & Tenancy (Epic 14) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter. +| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. | +> 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. +> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows. + +## Observability & Forensics (Epic 15) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. | +| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. | +| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. | + +## Air-Gapped Mode (Epic 16) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. | +| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. | +> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`. +> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests). +| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. | +> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild). + +## SDKs & OpenAPI (Epic 17) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs. +> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions. +| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. | +> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour. +| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. | +> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild). diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md index 87124829..8bf986fd 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md @@ -1,4 +1,4 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| -|FEEDCONN-SHARED-STATE-003 Source state seeding helper|Tools Guild, BE-Conn-MSRC|Tools|**DOING (2025-10-19)** – Provide a reusable CLI/utility to seed `pendingDocuments`/`pendingMappings` for connectors (MSRC backfills require scripted CVRF + detail injection). Coordinate with MSRC team for expected JSON schema and handoff once prototype lands. Prereqs confirmed none (2025-10-19).| +|FEEDCONN-SHARED-STATE-003 Source state seeding helper|Tools Guild, BE-Conn-MSRC|Tools|**DONE (2025-11-04)** – Shipped `src/Tools/SourceStateSeeder` CLI plus `SourceStateSeedProcessor` APIs for programmatic seeding, with Mongo fixtures and MSRC runbook updates. Tests: `dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.Common.Tests/StellaOps.Concelier.Connector.Common.Tests.csproj --no-build` (requires `libcrypto.so.1.1` for Mongo2Go when running outside CI).| diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md index e0b66ab7..b9abc2d7 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Distro.RedHat/TASKS.md @@ -1,4 +1,4 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| -|Fixture validation sweep|QA|None|**DOING (2025-10-19)** – Prereqs confirmed none; continuing RHSA fixture regeneration and diff review alongside mapper provenance updates.
2025-10-29: Added `scripts/update-redhat-fixtures.sh` to regenerate golden snapshots with `UPDATE_GOLDENS=1`; run it before reviews to capture CSAF contract deltas.| +|Fixture validation sweep|QA|None|**DONE (2025-11-04)** – Regenerated RHSA golden fixtures with `scripts/update-redhat-fixtures.sh` (exports `UPDATE_GOLDENS=1`) and revalidated connector snapshots via `dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.Distro.RedHat.Tests/StellaOps.Concelier.Connector.Distro.RedHat.Tests.csproj --no-restore`.| diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md index ed04d1f9..48f7de9b 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md @@ -1,7 +1,7 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| -|Link-Not-Merge version provenance coordination|BE-Merge|CONCELIER-LNM-21-001|**DOING** – Coordinate remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) so they emit `advisory_observations.affected.versions[]` entries with provenance tags and deterministic comparison keys. Track rollout status in `docs/dev/normalized-rule-recipes.md` (now updated for Link-Not-Merge) and retire the legacy merge counters as coverage transitions to linkset validation metrics.
2025-10-29: Added new guidance in the doc for recording observation version metadata and logging gaps via `LinksetVersionCoverage` warnings to replace prior `concelier.merge.normalized_rules*` alerts.| +|Link-Not-Merge version provenance coordination|BE-Merge|CONCELIER-LNM-21-001|**DONE (2025-11-04)** – Coordinated connector rollout: updated `docs/dev/normalized-rule-recipes.md` with a per-connector status table + follow-up IDs, enabled `Normalized version rules missing` diagnostics in `AdvisoryPrecedenceMerger`, and confirmed Linkset validation metrics reflect remaining upstream gaps (ACSC/CCCS/CERTBUND/Cisco/RU-BDU awaiting structured ranges).| |FEEDMERGE-COORD-02-901 Connector deadline check-ins|BE-Merge|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-21)** – Confirm Cccs/Cisco version-provenance updates land, capture `LinksetVersionCoverage` dashboard snapshots (expect zero missing-range warnings), and update coordination docs with the results.
2025-10-29: Observation metrics now surface `version_entries_total`/`missing_version_entries_total`; include screenshots for both when closing this task.| |FEEDMERGE-COORD-02-902 ICS-CISA version comparison support|BE-Merge, Models|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-23)** – Review ICS-CISA sample advisories, validate reuse of existing comparison helpers, and pre-stage Models ticket template only if a new firmware comparator is required. Document the outcome and observation coverage logs in coordination docs + tracker files.
2025-10-29: `docs/dev/normalized-rule-recipes.md` (§2–§3) now covers observation entries; attach decision summary + log sample when handing off to Models.| |FEEDMERGE-COORD-02-903 KISA firmware scheme review|BE-Merge, Models|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-24)** – Pair with KISA team on proposed firmware comparison helper (`kisa.build` or variant), ensure observation mapper alignment, and open Models ticket only if a new comparator is required. Log the final helper signature and observation coverage metrics in coordination docs + tracker files.| diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs index 76298df8..4bcb6a69 100644 --- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs @@ -120,7 +120,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient("ledger-policy-engine"); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/InlinePolicyEvaluationService.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/InlinePolicyEvaluationService.cs index 0edb4473..c1ff57a8 100644 --- a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/InlinePolicyEvaluationService.cs +++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/InlinePolicyEvaluationService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using StellaOps.Findings.Ledger.Domain; diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEngineEvaluationService.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEngineEvaluationService.cs new file mode 100644 index 00000000..562ba358 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEngineEvaluationService.cs @@ -0,0 +1,217 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Domain; +using StellaOps.Findings.Ledger.Options; + +namespace StellaOps.Findings.Ledger.Infrastructure.Policy; + +internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private readonly HttpClient? _httpClient; + private readonly InlinePolicyEvaluationService _fallback; + private readonly PolicyEvaluationCache _cache; + private readonly LedgerServiceOptions.PolicyEngineOptions _options; + private readonly ILogger _logger; + + public PolicyEngineEvaluationService( + IHttpClientFactory httpClientFactory, + InlinePolicyEvaluationService fallback, + PolicyEvaluationCache cache, + IOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpClientFactory); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.PolicyEngine; + + if (_options.BaseAddress is not null) + { + var client = httpClientFactory.CreateClient("ledger-policy-engine"); + client.BaseAddress = _options.BaseAddress; + client.Timeout = _options.RequestTimeout; + _httpClient = client; + } + } + + public async Task EvaluateAsync( + LedgerEventRecord record, + FindingProjection? existingProjection, + CancellationToken cancellationToken) + { + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + if (_httpClient is null) + { + return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false); + } + + var cacheKey = CreateCacheKey(record, existingProjection); + if (_cache.TryGet(cacheKey, out var cachedResult)) + { + return cachedResult; + } + + try + { + var requestBody = CreateRequest(record, existingProjection); + using var request = new HttpRequestMessage(HttpMethod.Post, "policy/eval/batch") + { + Content = JsonContent.Create(requestBody, options: SerializerOptions) + }; + + request.Headers.TryAddWithoutValidation(_options.TenantHeaderName, record.TenantId); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Policy engine evaluation request failed with status {StatusCode}. Falling back to inline evaluator.", + response.StatusCode); + return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false); + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + var result = ParseResponse(document.RootElement, record); + _cache.Set(cacheKey, result); + return result; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Policy engine evaluation failed; falling back to inline evaluation."); + return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false); + } + } + + private static PolicyEvaluationCacheKey CreateCacheKey(LedgerEventRecord record, FindingProjection? existingProjection) + { + using var sha = SHA256.Create(); + var eventBytes = JsonSerializer.SerializeToUtf8Bytes(record.EventBody, SerializerOptions); + var hashBytes = sha.ComputeHash(eventBytes); + var projectionHash = existingProjection?.CycleHash; + return new PolicyEvaluationCacheKey(record.TenantId, record.PolicyVersion, record.EventId, projectionHash ?? Convert.ToHexString(hashBytes)); + } + + private static JsonObject CreateRequest(LedgerEventRecord record, FindingProjection? existingProjection) + { + var batchItem = new JsonObject + { + ["findingId"] = record.FindingId, + ["eventId"] = record.EventId.ToString(), + ["event"] = record.EventBody.DeepClone() + }; + + if (existingProjection is not null) + { + batchItem["currentProjection"] = new JsonObject + { + ["status"] = existingProjection.Status, + ["severity"] = existingProjection.Severity, + ["labels"] = existingProjection.Labels.DeepClone(), + ["explainRef"] = existingProjection.ExplainRef, + ["rationale"] = existingProjection.PolicyRationale.DeepClone() + }; + } + + var request = new JsonObject + { + ["tenantId"] = record.TenantId, + ["policyVersion"] = record.PolicyVersion, + ["items"] = new JsonArray { batchItem } + }; + + return request; + } + + private static PolicyEvaluationResult ParseResponse(JsonElement response, LedgerEventRecord record) + { + if (!response.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Policy engine response missing 'items' array."); + } + + foreach (var item in itemsElement.EnumerateArray()) + { + var findingId = item.GetPropertyOrDefault("findingId")?.GetString(); + if (!string.Equals(findingId, record.FindingId, StringComparison.Ordinal)) + { + continue; + } + + var status = item.GetPropertyOrDefault("status")?.GetString(); + decimal? severity = null; + var severityElement = item.GetPropertyOrDefault("severity"); + if (severityElement.HasValue && severityElement.Value.ValueKind == JsonValueKind.Number && severityElement.Value.TryGetDecimal(out var decimalSeverity)) + { + severity = decimalSeverity; + } + + var labelsNode = new JsonObject(); + var labelsElement = item.GetPropertyOrDefault("labels"); + if (labelsElement.HasValue && labelsElement.Value.ValueKind == JsonValueKind.Object) + { + labelsNode = (JsonObject)labelsElement.Value.ToJsonNode()!; + } + var explainRef = item.GetPropertyOrDefault("explainRef")?.GetString(); + + JsonArray rationale; + var rationaleElement = item.GetPropertyOrDefault("rationale"); + if (!rationaleElement.HasValue || rationaleElement.Value.ValueKind != JsonValueKind.Array) + { + rationale = new JsonArray(); + if (!string.IsNullOrWhiteSpace(explainRef)) + { + rationale.Add(explainRef); + } + } + else + { + rationale = (JsonArray)rationaleElement.Value.ToJsonNode()!; + } + + return new PolicyEvaluationResult(status, severity, labelsNode, explainRef, rationale); + } + + throw new InvalidOperationException("Policy engine response did not include evaluation for requested finding."); + } +} + +internal static class JsonElementExtensions +{ + public static JsonElement? GetPropertyOrDefault(this JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + return element.TryGetProperty(propertyName, out var value) ? value : null; + } + + public static JsonNode? ToJsonNode(this JsonElement element) + { + return JsonNode.Parse(element.GetRawText()); + } +} diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEvaluationCache.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEvaluationCache.cs new file mode 100644 index 00000000..0e7bbdae --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Policy/PolicyEvaluationCache.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using StellaOps.Findings.Ledger.Domain; +using StellaOps.Findings.Ledger.Options; + +namespace StellaOps.Findings.Ledger.Infrastructure.Policy; + +internal sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash); + +internal sealed class PolicyEvaluationCache : IDisposable +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private bool _disposed; + + public PolicyEvaluationCache( + LedgerServiceOptions.PolicyEngineOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = options.Cache.SizeLimit + }); + + EntryLifetime = options.Cache.EntryLifetime; + } + + public TimeSpan EntryLifetime { get; } + + public bool TryGet(PolicyEvaluationCacheKey key, out PolicyEvaluationResult result) + { + ArgumentNullException.ThrowIfNull(key); + + if (_cache.TryGetValue(key, out PolicyEvaluationResult? cached) && cached is not null) + { + _logger.LogTrace("Policy evaluation cache hit for tenant {Tenant} finding {Finding} policy {Policy}", key.TenantId, key.EventId, key.PolicyVersion); + result = Clone(cached); + return true; + } + + result = null!; + return false; + } + + public void Set(PolicyEvaluationCacheKey key, PolicyEvaluationResult value) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + var entryOptions = new MemoryCacheEntryOptions() + .SetSize(1) + .SetAbsoluteExpiration(EntryLifetime); + + _cache.Set(key, Clone(value), entryOptions); + } + + private static PolicyEvaluationResult Clone(PolicyEvaluationResult result) + { + var labelsClone = result.Labels is null ? new JsonObject() : (JsonObject)result.Labels.DeepClone(); + var rationaleClone = result.Rationale is null ? new JsonArray() : CloneArray(result.Rationale); + + return new PolicyEvaluationResult( + result.Status, + result.Severity, + labelsClone, + result.ExplainRef, + rationaleClone); + } + + private static JsonArray CloneArray(JsonArray source) + { + var clone = new JsonArray(); + foreach (var item in source) + { + clone.Add(item?.DeepClone()); + } + + return clone; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _cache.Dispose(); + _disposed = true; + } +} diff --git a/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs b/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs index ea97529e..5e7d3436 100644 --- a/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs +++ b/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs @@ -12,6 +12,8 @@ public sealed class LedgerServiceOptions public ProjectionOptions Projection { get; init; } = new(); + public PolicyEngineOptions PolicyEngine { get; init; } = new(); + public void Validate() { if (string.IsNullOrWhiteSpace(Database.ConnectionString)) @@ -43,6 +45,8 @@ public sealed class LedgerServiceOptions { throw new InvalidOperationException("Projection idle delay must be greater than zero."); } + + PolicyEngine.Validate(); } public sealed class DatabaseOptions @@ -90,4 +94,53 @@ public sealed class LedgerServiceOptions public TimeSpan IdleDelay { get; set; } = DefaultIdleDelay; } + + public sealed class PolicyEngineOptions + { + private const int DefaultCacheSizeLimit = 2048; + private static readonly TimeSpan DefaultCacheEntryLifetime = TimeSpan.FromMinutes(30); + private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(10); + + public Uri? BaseAddress { get; set; } + + public string TenantHeaderName { get; set; } = "X-Stella-Tenant"; + + public TimeSpan RequestTimeout { get; set; } = DefaultRequestTimeout; + + public PolicyEngineCacheOptions Cache { get; init; } = new(); + + internal void Validate() + { + if (BaseAddress is not null && !BaseAddress.IsAbsoluteUri) + { + throw new InvalidOperationException("Policy engine base address must be an absolute URI."); + } + + if (Cache.SizeLimit <= 0) + { + throw new InvalidOperationException("Policy engine cache size limit must be greater than zero."); + } + + if (Cache.EntryLifetime <= TimeSpan.Zero) + { + throw new InvalidOperationException("Policy engine cache entry lifetime must be greater than zero."); + } + + if (RequestTimeout <= TimeSpan.Zero) + { + throw new InvalidOperationException("Policy engine request timeout must be greater than zero."); + } + } + } + + public sealed class PolicyEngineCacheOptions + { + private const int DefaultCacheSizeLimit = 2048; + private static readonly TimeSpan DefaultCacheEntryLifetime = TimeSpan.FromMinutes(30); + + public int SizeLimit { get; set; } = DefaultCacheSizeLimit; + + public TimeSpan EntryLifetime { get; set; } = DefaultCacheEntryLifetime; + } + } diff --git a/src/Findings/StellaOps.Findings.Ledger/Properties/AssemblyInfo.cs b/src/Findings/StellaOps.Findings.Ledger/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..08d5deec --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Findings.Ledger.Tests")] diff --git a/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj b/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj index f9f57cd5..45741ab3 100644 --- a/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj +++ b/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj @@ -14,6 +14,8 @@ + + diff --git a/src/Findings/StellaOps.Findings.Ledger/TASKS.md b/src/Findings/StellaOps.Findings.Ledger/TASKS.md index f1bb1ad8..d06275ec 100644 --- a/src/Findings/StellaOps.Findings.Ledger/TASKS.md +++ b/src/Findings/StellaOps.Findings.Ledger/TASKS.md @@ -4,7 +4,7 @@ | LEDGER-29-001 | DONE (2025-11-03) | Findings Ledger Guild | AUTH-POLICY-27-001 | Design ledger & projection schemas (tables/indexes), canonical JSON format, hashing strategy, and migrations. Publish schema doc + fixtures.
2025-11-03: Initial PostgreSQL migration added with partitions/enums, fixtures seeded with canonical hashes, schema doc aligned. | Schemas committed; migrations generated; hashing documented; fixtures seeded for CI. | | LEDGER-29-002 | DONE (2025-11-03) | Findings Ledger Guild | LEDGER-29-001 | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.
2025-11-03: Minimal web service scaffolded with canonical hashing, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests for hashing + conflict flows. | Events persisted with chained hashes; Merkle job emits anchors; unit/integration tests cover happy/pathological cases. | | LEDGER-29-003 | DONE (2025-11-03) | Findings Ledger Guild, Scheduler Guild | LEDGER-29-001 | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`. | Postgres-backed projector worker and reducers landed with replay checkpointing, fixtures, and tests. | -| LEDGER-29-004 | DOING (2025-11-03) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Projection reducer now consumes policy evaluation output with rationale arrays; Postgres migration + fixtures/tests updated, awaiting Policy Engine API wiring for batch fetch. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. | +| LEDGER-29-004 | DONE (2025-11-04) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Remote evaluation service wired via typed HttpClient, cache, and fallback inline evaluator; `/api/policy/eval/batch` documented; `policy_rationale` persisted with deterministic hashing; ledger tests `dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore` green. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. | | LEDGER-29-005 | TODO | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. | | LEDGER-29-006 | TODO | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. | | LEDGER-29-007 | TODO | Findings Ledger Guild, Observability Guild | LEDGER-29-002..005 | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. | Metrics/traces emitted; dashboards live; alert thresholds documented. | diff --git a/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/LedgerProjectionReducerTests.cs b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/LedgerProjectionReducerTests.cs index 85e576bf..14a72e07 100644 --- a/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/LedgerProjectionReducerTests.cs +++ b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/LedgerProjectionReducerTests.cs @@ -65,6 +65,7 @@ public sealed class LedgerProjectionReducerTests new JsonObject(), Guid.NewGuid(), null, + new JsonArray(), DateTimeOffset.UtcNow, string.Empty); var existingHash = ProjectionHashing.ComputeCycleHash(existing); @@ -112,6 +113,7 @@ public sealed class LedgerProjectionReducerTests labels, Guid.NewGuid(), null, + new JsonArray(), DateTimeOffset.UtcNow, string.Empty); existing = existing with { CycleHash = ProjectionHashing.ComputeCycleHash(existing) }; diff --git a/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/PolicyEngineEvaluationServiceTests.cs b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/PolicyEngineEvaluationServiceTests.cs new file mode 100644 index 00000000..3f6d60cd --- /dev/null +++ b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/PolicyEngineEvaluationServiceTests.cs @@ -0,0 +1,186 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Domain; +using StellaOps.Findings.Ledger.Infrastructure.Policy; +using StellaOps.Findings.Ledger.Options; +using Xunit; + +namespace StellaOps.Findings.Ledger.Tests; + +public sealed class PolicyEngineEvaluationServiceTests +{ + private const string TenantId = "tenant-1"; + private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow; + + [Fact] + public async Task EvaluateAsync_UsesPolicyEngineAndCachesResult() + { + var handler = new StubHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "items": [ + { + "findingId": "finding-1", + "status": "affected", + "severity": 7.5, + "labels": { "exposure": "runtime" }, + "explainRef": "policy://explain/123", + "rationale": ["policy://explain/123"] + } + ] + } + """, + System.Text.Encoding.UTF8, + "application/json") + }); + + var factory = new TestHttpClientFactory(handler); + var options = CreateOptions(new Uri("https://policy.example/")); + using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); + var inline = new InlinePolicyEvaluationService(NullLogger.Instance); + var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); + + var record = CreateRecord(); + + var first = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); + var second = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); + + Assert.Equal("affected", first.Status); + Assert.Equal(7.5m, first.Severity); + Assert.Equal("policy://explain/123", first.ExplainRef); + Assert.Equal("runtime", first.Labels?["exposure"]?.GetValue()); + Assert.Equal(1, handler.CallCount); // cached second call + Assert.Equal("affected", second.Status); + Assert.Equal("policy://explain/123", second.Rationale[0]?.GetValue()); + } + + [Fact] + public async Task EvaluateAsync_FallsBackToInlineWhenRequestFails() + { + var handler = new StubHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + var factory = new TestHttpClientFactory(handler); + var options = CreateOptions(new Uri("https://policy.example/")); + using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); + var inline = new InlinePolicyEvaluationService(NullLogger.Instance); + var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); + + var record = CreateRecord(payloadStatus: "investigating", payloadSeverity: 4.2m); + + var result = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); + + Assert.Equal("investigating", result.Status); + Assert.Equal(4.2m, result.Severity); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task EvaluateAsync_UsesInlineWhenNoBaseAddressConfigured() + { + var handler = new StubHttpHandler(_ => throw new InvalidOperationException("Handler should not be invoked.")); + var factory = new TestHttpClientFactory(handler); + var options = CreateOptions(baseAddress: null); + using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); + var inline = new InlinePolicyEvaluationService(NullLogger.Instance); + var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); + + var record = CreateRecord(payloadStatus: "accepted_risk", payloadSeverity: 1.0m); + + var result = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); + + Assert.Equal("accepted_risk", result.Status); + Assert.Equal(1.0m, result.Severity); + Assert.Equal(0, handler.CallCount); + } + + private static LedgerServiceOptions CreateOptions(Uri? baseAddress) + { + return new LedgerServiceOptions + { + PolicyEngine = new LedgerServiceOptions.PolicyEngineOptions + { + BaseAddress = baseAddress, + Cache = new LedgerServiceOptions.PolicyEngineCacheOptions + { + SizeLimit = 16, + EntryLifetime = TimeSpan.FromMinutes(5) + } + } + }; + } + + private static LedgerEventRecord CreateRecord(string payloadStatus = "affected", decimal? payloadSeverity = 5.0m) + { + var payload = new JsonObject + { + ["status"] = payloadStatus, + ["severity"] = payloadSeverity, + ["labels"] = new JsonObject { ["source"] = "policy" } + }; + + var envelope = new JsonObject + { + ["event"] = new JsonObject + { + ["payload"] = payload + } + }; + + return new LedgerEventRecord( + TenantId, + Guid.NewGuid(), + 1, + Guid.NewGuid(), + LedgerEventConstants.EventFindingStatusChanged, + "policy/v1", + "finding-1", + "artifact-1", + null, + "actor", + "service", + Now, + Now, + envelope, + "hash", + "prev", + "leaf", + envelope.ToJsonString()); + } + + private sealed class StubHttpHandler : HttpMessageHandler + { + private readonly Func _handler; + + public StubHttpHandler(Func handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(_handler(request)); + } + } + + private sealed class TestHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public TestHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler, disposeHandler: false); + } + } +} diff --git a/src/IssuerDirectory/StellaOps.IssuerDirectory/TASKS.md b/src/IssuerDirectory/StellaOps.IssuerDirectory/TASKS.md index 827e2abc..9326abd2 100644 --- a/src/IssuerDirectory/StellaOps.IssuerDirectory/TASKS.md +++ b/src/IssuerDirectory/StellaOps.IssuerDirectory/TASKS.md @@ -1,12 +1,12 @@ -# Issuer Directory Task Board — Epic 7 -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| ISSUER-30-001 | DONE (2025-11-01) | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. | -| ISSUER-30-002 | DONE (2025-11-01) | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. | -| ISSUER-30-003 | DONE (2025-11-04) | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. | -> 2025-11-04: `/issuer-directory/issuers/{id}/trust` endpoints deliver bounded overrides with audit logging, Mongo indexes seeded for uniqueness, config/docs updated, and core tests executed (`dotnet test`). -| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitor signature verification (client SDK, caching, retries). | Lens/Excitor resolve issuer metadata via SDK; integration tests cover network failures. | -| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. | -| ISSUER-30-006 | DONE (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. | - -> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes. +# Issuer Directory Task Board — Epic 7 +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| ISSUER-30-001 | DONE (2025-11-01) | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. | +| ISSUER-30-002 | DONE (2025-11-01) | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. | +| ISSUER-30-003 | DONE (2025-11-04) | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. | +> 2025-11-04: `/issuer-directory/issuers/{id}/trust` endpoints deliver bounded overrides with audit logging, Mongo indexes seeded for uniqueness, config/docs updated, and core tests executed (`dotnet test`). +| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitor signature verification (client SDK, caching, retries). | Lens/Excitor resolve issuer metadata via SDK; integration tests cover network failures. | +| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. | +| ISSUER-30-006 | DONE (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. | + +> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes. diff --git a/src/Notify/plugins/notify/email/notify-plugin.json b/src/Notify/plugins/notify/email/notify-plugin.json index 56407f5f..097ec83d 100644 --- a/src/Notify/plugins/notify/email/notify-plugin.json +++ b/src/Notify/plugins/notify/email/notify-plugin.json @@ -1,18 +1,18 @@ -{ - "schemaVersion": "1.0", - "id": "stellaops.notify.connector.email", - "displayName": "StellaOps Email Notify Connector", - "version": "0.1.0-alpha", - "requiresRestart": true, - "entryPoint": { - "type": "dotnet", - "assembly": "StellaOps.Notify.Connectors.Email.dll" - }, - "capabilities": [ - "notify-connector", - "email" - ], - "metadata": { - "org.stellaops.notify.channel.type": "email" - } -} +{ + "schemaVersion": "1.0", + "id": "stellaops.notify.connector.email", + "displayName": "StellaOps Email Notify Connector", + "version": "0.1.0-alpha", + "requiresRestart": true, + "entryPoint": { + "type": "dotnet", + "assembly": "StellaOps.Notify.Connectors.Email.dll" + }, + "capabilities": [ + "notify-connector", + "email" + ], + "metadata": { + "org.stellaops.notify.channel.type": "email" + } +} diff --git a/src/Notify/plugins/notify/slack/notify-plugin.json b/src/Notify/plugins/notify/slack/notify-plugin.json index 95fb1dfb..1d7efde1 100644 --- a/src/Notify/plugins/notify/slack/notify-plugin.json +++ b/src/Notify/plugins/notify/slack/notify-plugin.json @@ -1,19 +1,19 @@ -{ - "schemaVersion": "1.0", - "id": "stellaops.notify.connector.slack", - "displayName": "StellaOps Slack Notify Connector", - "version": "0.1.0-alpha", - "requiresRestart": true, - "entryPoint": { - "type": "dotnet", - "assembly": "StellaOps.Notify.Connectors.Slack.dll" - }, - "capabilities": [ - "notify-connector", - "slack" - ], - "metadata": { - "org.stellaops.notify.channel.type": "slack", - "org.stellaops.notify.connector.requiredScopes": "chat:write,chat:write.public" - } -} +{ + "schemaVersion": "1.0", + "id": "stellaops.notify.connector.slack", + "displayName": "StellaOps Slack Notify Connector", + "version": "0.1.0-alpha", + "requiresRestart": true, + "entryPoint": { + "type": "dotnet", + "assembly": "StellaOps.Notify.Connectors.Slack.dll" + }, + "capabilities": [ + "notify-connector", + "slack" + ], + "metadata": { + "org.stellaops.notify.channel.type": "slack", + "org.stellaops.notify.connector.requiredScopes": "chat:write,chat:write.public" + } +} diff --git a/src/Notify/plugins/notify/teams/notify-plugin.json b/src/Notify/plugins/notify/teams/notify-plugin.json index 78239596..756b1298 100644 --- a/src/Notify/plugins/notify/teams/notify-plugin.json +++ b/src/Notify/plugins/notify/teams/notify-plugin.json @@ -1,19 +1,19 @@ -{ - "schemaVersion": "1.0", - "id": "stellaops.notify.connector.teams", - "displayName": "StellaOps Teams Notify Connector", - "version": "0.1.0-alpha", - "requiresRestart": true, - "entryPoint": { - "type": "dotnet", - "assembly": "StellaOps.Notify.Connectors.Teams.dll" - }, - "capabilities": [ - "notify-connector", - "teams" - ], - "metadata": { - "org.stellaops.notify.channel.type": "teams", - "org.stellaops.notify.connector.cardVersion": "1.5" - } -} +{ + "schemaVersion": "1.0", + "id": "stellaops.notify.connector.teams", + "displayName": "StellaOps Teams Notify Connector", + "version": "0.1.0-alpha", + "requiresRestart": true, + "entryPoint": { + "type": "dotnet", + "assembly": "StellaOps.Notify.Connectors.Teams.dll" + }, + "capabilities": [ + "notify-connector", + "teams" + ], + "metadata": { + "org.stellaops.notify.channel.type": "teams", + "org.stellaops.notify.connector.cardVersion": "1.5" + } +} diff --git a/src/Notify/plugins/notify/webhook/notify-plugin.json b/src/Notify/plugins/notify/webhook/notify-plugin.json index 32b4ead7..fb17679b 100644 --- a/src/Notify/plugins/notify/webhook/notify-plugin.json +++ b/src/Notify/plugins/notify/webhook/notify-plugin.json @@ -1,18 +1,18 @@ -{ - "schemaVersion": "1.0", - "id": "stellaops.notify.connector.webhook", - "displayName": "StellaOps Webhook Notify Connector", - "version": "0.1.0-alpha", - "requiresRestart": true, - "entryPoint": { - "type": "dotnet", - "assembly": "StellaOps.Notify.Connectors.Webhook.dll" - }, - "capabilities": [ - "notify-connector", - "webhook" - ], - "metadata": { - "org.stellaops.notify.channel.type": "webhook" - } -} +{ + "schemaVersion": "1.0", + "id": "stellaops.notify.connector.webhook", + "displayName": "StellaOps Webhook Notify Connector", + "version": "0.1.0-alpha", + "requiresRestart": true, + "entryPoint": { + "type": "dotnet", + "assembly": "StellaOps.Notify.Connectors.Webhook.dll" + }, + "capabilities": [ + "notify-connector", + "webhook" + ], + "metadata": { + "org.stellaops.notify.channel.type": "webhook" + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/ReportContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/ReportContracts.cs index f64e1a2f..bfa751b2 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/ReportContracts.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/ReportContracts.cs @@ -55,9 +55,13 @@ public sealed record ReportDocumentDto [JsonPropertyOrder(6)] public IReadOnlyList Verdicts { get; init; } = Array.Empty(); - [JsonPropertyName("issues")] - [JsonPropertyOrder(7)] - public IReadOnlyList Issues { get; init; } = Array.Empty(); + [JsonPropertyName("issues")] + [JsonPropertyOrder(7)] + public IReadOnlyList Issues { get; init; } = Array.Empty(); + + [JsonPropertyName("surface")] + [JsonPropertyOrder(8)] + public SurfacePointersDto? Surface { get; init; } } public sealed record ReportPolicyDto diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/ScanStatusResponse.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/ScanStatusResponse.cs index 9eb05279..58e2ba1c 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/ScanStatusResponse.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/ScanStatusResponse.cs @@ -1,13 +1,14 @@ -namespace StellaOps.Scanner.WebService.Contracts; - -public sealed record ScanStatusResponse( - string ScanId, - string Status, - ScanStatusTarget Image, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - string? FailureReason); - -public sealed record ScanStatusTarget( - string? Reference, - string? Digest); +namespace StellaOps.Scanner.WebService.Contracts; + +public sealed record ScanStatusResponse( + string ScanId, + string Status, + ScanStatusTarget Image, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + string? FailureReason, + SurfacePointersDto? Surface); + +public sealed record ScanStatusTarget( + string? Reference, + string? Digest); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs new file mode 100644 index 00000000..7644be67 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.WebService.Contracts; + +public sealed record SurfacePointersDto +{ + [JsonPropertyName("tenant")] + [JsonPropertyOrder(0)] + public string Tenant { get; init; } = string.Empty; + + [JsonPropertyName("generatedAt")] + [JsonPropertyOrder(1)] + public DateTimeOffset GeneratedAt { get; init; } + = DateTimeOffset.UtcNow; + + [JsonPropertyName("manifestDigest")] + [JsonPropertyOrder(2)] + public string ManifestDigest { get; init; } = string.Empty; + + [JsonPropertyName("manifestUri")] + [JsonPropertyOrder(3)] + public string? ManifestUri { get; init; } + = null; + + [JsonPropertyName("manifest")] + [JsonPropertyOrder(4)] + public SurfaceManifestDocument Manifest { get; init; } = new(); +} + +public sealed record SurfaceManifestDocument +{ + [JsonPropertyName("schema")] + [JsonPropertyOrder(0)] + public string Schema { get; init; } = "stellaops.surface.manifest@1"; + + [JsonPropertyName("tenant")] + [JsonPropertyOrder(1)] + public string Tenant { get; init; } = string.Empty; + + [JsonPropertyName("imageDigest")] + [JsonPropertyOrder(2)] + public string ImageDigest { get; init; } = string.Empty; + + [JsonPropertyName("generatedAt")] + [JsonPropertyOrder(3)] + public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; + + [JsonPropertyName("artifacts")] + [JsonPropertyOrder(4)] + public IReadOnlyList Artifacts { get; init; } = Array.Empty(); +} + +public sealed record SurfaceManifestArtifact +{ + [JsonPropertyName("kind")] + [JsonPropertyOrder(0)] + public string Kind { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + [JsonPropertyOrder(1)] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("digest")] + [JsonPropertyOrder(2)] + public string Digest { get; init; } = string.Empty; + + [JsonPropertyName("mediaType")] + [JsonPropertyOrder(3)] + public string MediaType { get; init; } = string.Empty; + + [JsonPropertyName("format")] + [JsonPropertyOrder(4)] + public string Format { get; init; } = string.Empty; + + [JsonPropertyName("sizeBytes")] + [JsonPropertyOrder(5)] + public long SizeBytes { get; init; } + = 0; + + [JsonPropertyName("view")] + [JsonPropertyOrder(6)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? View { get; init; } + = null; +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs index 0268f2e9..afaa4a0c 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/HealthEndpoints.cs @@ -1,12 +1,16 @@ -using System.Diagnostics; +using System.Collections.Generic; +using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; -using StellaOps.Scanner.WebService.Diagnostics; -using StellaOps.Scanner.WebService.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.WebService.Diagnostics; +using StellaOps.Scanner.WebService.Options; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.Validation; namespace StellaOps.Scanner.WebService.Endpoints; @@ -56,27 +60,69 @@ internal static class HealthEndpoints return Json(document, StatusCodes.Status200OK); } - private static async Task HandleReady( - ServiceStatus status, - HttpContext context, - CancellationToken cancellationToken) - { - ApplyNoCache(context.Response); - - await Task.CompletedTask; - - status.RecordReadyCheck(success: true, latency: TimeSpan.Zero, error: null); - var snapshot = status.CreateSnapshot(); - var ready = snapshot.Ready; - - var document = new ReadyDocument( - Status: ready.IsReady ? "ready" : "unready", - CheckedAt: ready.CheckedAt, - LatencyMs: ready.Latency?.TotalMilliseconds, - Error: ready.Error); - - return Json(document, StatusCodes.Status200OK); - } + private static async Task HandleReady( + ServiceStatus status, + ISurfaceValidatorRunner validatorRunner, + ISurfaceEnvironment surfaceEnvironment, + ILoggerFactory loggerFactory, + HttpContext context, + CancellationToken cancellationToken) + { + ApplyNoCache(context.Response); + + ArgumentNullException.ThrowIfNull(loggerFactory); + + var logger = loggerFactory.CreateLogger("Scanner.WebService.Health"); + var stopwatch = Stopwatch.StartNew(); + var success = true; + string? error = null; + + try + { + var validationContext = SurfaceValidationContext.Create( + context.RequestServices, + "Scanner.WebService.ReadyCheck", + surfaceEnvironment.Settings, + properties: new Dictionary + { + ["path"] = context.Request.Path.ToString() + }); + + await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (SurfaceValidationException ex) + { + success = false; + error = ex.Message; + } + catch (Exception ex) + { + success = false; + error = ex.Message; + logger.LogError(ex, "Surface validation failed during ready check."); + } + finally + { + stopwatch.Stop(); + } + + status.RecordReadyCheck(success, stopwatch.Elapsed, error); + var snapshot = status.CreateSnapshot(); + var ready = snapshot.Ready; + + var document = new ReadyDocument( + Status: ready.IsReady ? "ready" : "unready", + CheckedAt: ready.CheckedAt, + LatencyMs: ready.Latency?.TotalMilliseconds, + Error: ready.Error); + + var statusCode = success ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable; + return Json(document, statusCode); + } private static void ApplyNoCache(HttpResponse response) { diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs index 4c4fef6f..12a063c7 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs @@ -1,17 +1,18 @@ using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using StellaOps.Policy; -using StellaOps.Scanner.WebService.Constants; -using StellaOps.Scanner.WebService.Contracts; -using StellaOps.Scanner.WebService.Infrastructure; -using StellaOps.Scanner.WebService.Security; -using StellaOps.Scanner.WebService.Services; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using StellaOps.Policy; +using StellaOps.Scanner.WebService.Constants; +using StellaOps.Scanner.WebService.Contracts; +using StellaOps.Scanner.WebService.Infrastructure; +using StellaOps.Scanner.WebService.Security; +using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Endpoints; @@ -49,25 +50,30 @@ internal static class ReportEndpoints }); } - private static async Task HandleCreateReportAsync( - ReportRequestDto request, - PolicyPreviewService previewService, - IReportSigner signer, - TimeProvider timeProvider, - IReportEventDispatcher eventDispatcher, - HttpContext context, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(previewService); - ArgumentNullException.ThrowIfNull(signer); - ArgumentNullException.ThrowIfNull(timeProvider); - ArgumentNullException.ThrowIfNull(eventDispatcher); - - if (string.IsNullOrWhiteSpace(request.ImageDigest)) - { - return ProblemResultFactory.Create( - context, + private static async Task HandleCreateReportAsync( + ReportRequestDto request, + PolicyPreviewService previewService, + IReportSigner signer, + TimeProvider timeProvider, + IReportEventDispatcher eventDispatcher, + ISurfacePointerService surfacePointerService, + ILoggerFactory loggerFactory, + HttpContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(previewService); + ArgumentNullException.ThrowIfNull(signer); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(eventDispatcher); + ArgumentNullException.ThrowIfNull(surfacePointerService); + ArgumentNullException.ThrowIfNull(loggerFactory); + var logger = loggerFactory.CreateLogger("Scanner.WebService.Reports"); + + if (string.IsNullOrWhiteSpace(request.ImageDigest)) + { + return ProblemResultFactory.Create( + context, ProblemTypes.Validation, "Invalid report request", StatusCodes.Status400BadRequest, @@ -127,26 +133,46 @@ internal static class ReportEndpoints .ToArray(); var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray(); - var summary = BuildSummary(projectedVerdicts); - var verdict = ComputeVerdict(projectedVerdicts); - var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest); - var generatedAt = timeProvider.GetUtcNow(); - - var document = new ReportDocumentDto - { - ReportId = reportId, - ImageDigest = request.ImageDigest!, - GeneratedAt = generatedAt, - Verdict = verdict, - Policy = new ReportPolicyDto - { - RevisionId = preview.RevisionId, - Digest = preview.PolicyDigest - }, - Summary = summary, - Verdicts = projectedVerdicts, - Issues = issuesDto - }; + var summary = BuildSummary(projectedVerdicts); + var verdict = ComputeVerdict(projectedVerdicts); + var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest); + var generatedAt = timeProvider.GetUtcNow(); + SurfacePointersDto? surfacePointers = null; + + try + { + surfacePointers = await surfacePointerService + .TryBuildAsync(request.ImageDigest!, context.RequestAborted) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + if (!context.RequestAborted.IsCancellationRequested) + { + logger.LogDebug(ex, "Failed to build surface pointers for digest {Digest}.", request.ImageDigest); + } + } + + var document = new ReportDocumentDto + { + ReportId = reportId, + ImageDigest = request.ImageDigest!, + GeneratedAt = generatedAt, + Verdict = verdict, + Policy = new ReportPolicyDto + { + RevisionId = preview.RevisionId, + Digest = preview.PolicyDigest + }, + Summary = summary, + Verdicts = projectedVerdicts, + Issues = issuesDto, + Surface = surfacePointers + }; var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions); var signature = signer.Sign(payloadBytes); @@ -169,11 +195,11 @@ internal static class ReportEndpoints }; } - var response = new ReportResponseDto - { - Report = document, - Dsse = envelope - }; + var response = new ReportResponseDto + { + Report = document, + Dsse = envelope + }; await eventDispatcher .PublishAsync(request, preview, document, envelope, context, cancellationToken) diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs index 5ee7356e..86056786 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs @@ -140,10 +140,12 @@ internal static class ScanEndpoints private static async Task HandleStatusAsync( string scanId, IScanCoordinator coordinator, + ISurfacePointerService surfacePointerService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); + ArgumentNullException.ThrowIfNull(surfacePointerService); if (!ScanId.TryParse(scanId, out var parsed)) { @@ -163,7 +165,23 @@ internal static class ScanEndpoints ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, - detail: "Requested scan could not be located."); + detail: "Requested scan could not be located."); + } + + SurfacePointersDto? surfacePointers = null; + var digest = snapshot.Target.Digest; + if (!string.IsNullOrWhiteSpace(digest)) + { + try + { + surfacePointers = await surfacePointerService + .TryBuildAsync(digest!, context.RequestAborted) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + throw; + } } var response = new ScanStatusResponse( @@ -172,7 +190,8 @@ internal static class ScanEndpoints Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest), CreatedAt: snapshot.CreatedAt, UpdatedAt: snapshot.UpdatedAt, - FailureReason: snapshot.FailureReason); + FailureReason: snapshot.FailureReason, + Surface: surfacePointers); return Json(response, StatusCodes.Status200OK); } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs index a5178d8f..913c615a 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs @@ -1,5 +1,6 @@ -using System; -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using StellaOps.Scanner.Storage; namespace StellaOps.Scanner.WebService.Options; @@ -129,6 +130,8 @@ public sealed class ScannerWebServiceOptions public int ObjectLockRetentionDays { get; set; } = 30; + public string RootPrefix { get; set; } = ScannerStorageDefaults.DefaultRootPrefix; + public string? ApiKey { get; set; } public string ApiKeyHeader { get; set; } = string.Empty; diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 547f5d69..91758c5e 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -19,6 +19,10 @@ using StellaOps.Cryptography.DependencyInjection; using StellaOps.Cryptography.Plugin.BouncyCastle; using StellaOps.Policy; using StellaOps.Scanner.Cache; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.FS; +using StellaOps.Scanner.Surface.Secrets; +using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.WebService.Diagnostics; using StellaOps.Scanner.WebService.Endpoints; using StellaOps.Scanner.WebService.Extensions; @@ -80,11 +84,20 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddStellaOpsCrypto(); -builder.Services.AddBouncyCastleEd25519Provider(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddStellaOpsCrypto(); +builder.Services.AddBouncyCastleEd25519Provider(); builder.Services.AddSingleton(); +builder.Services.AddSurfaceEnvironment(options => +{ + options.ComponentName = "Scanner.WebService"; + options.AddPrefix("SCANNER"); +}); +builder.Services.AddSurfaceValidation(); +builder.Services.AddSurfaceFileCache(); +builder.Services.AddSurfaceSecrets(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); if (bootstrapOptions.Events is { Enabled: true } eventsOptions && string.Equals(eventsOptions.Driver, "redis", StringComparison.OrdinalIgnoreCase)) @@ -119,6 +132,11 @@ builder.Services.AddScannerStorage(storageOptions => storageOptions.ObjectStore.BucketName = bootstrapOptions.ArtifactStore.Bucket; } + if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.RootPrefix)) + { + storageOptions.ObjectStore.RootPrefix = bootstrapOptions.ArtifactStore.RootPrefix; + } + var artifactDriver = bootstrapOptions.ArtifactStore.Driver?.Trim() ?? string.Empty; if (string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.RustFs, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Scanner/StellaOps.Scanner.WebService/Serialization/OrchestratorEventSerializer.cs b/src/Scanner/StellaOps.Scanner.WebService/Serialization/OrchestratorEventSerializer.cs index f52aa71f..f5efcb26 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Serialization/OrchestratorEventSerializer.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Serialization/OrchestratorEventSerializer.cs @@ -207,10 +207,7 @@ internal static class OrchestratorEventSerializer return; } - info.PolymorphismOptions ??= new JsonPolymorphismOptions - { - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.Fail - }; + info.PolymorphismOptions ??= new JsonPolymorphismOptions(); AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload)); AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload)); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs new file mode 100644 index 00000000..dd335ad7 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Storage.Catalog; +using StellaOps.Scanner.Storage.ObjectStore; +using StellaOps.Scanner.Storage.Repositories; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.WebService.Contracts; +using StellaOps.Scanner.WebService.Options; + +namespace StellaOps.Scanner.WebService.Services; + +internal interface ISurfacePointerService +{ + Task TryBuildAsync(string imageDigest, CancellationToken cancellationToken); +} + +internal sealed class SurfacePointerService : ISurfacePointerService +{ + private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + private readonly LinkRepository _linkRepository; + private readonly ArtifactRepository _artifactRepository; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ISurfaceEnvironment _surfaceEnvironment; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public SurfacePointerService( + LinkRepository linkRepository, + ArtifactRepository artifactRepository, + IOptionsMonitor optionsMonitor, + ISurfaceEnvironment surfaceEnvironment, + TimeProvider timeProvider, + ILogger logger) + { + _linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository)); + _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository)); + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task TryBuildAsync(string imageDigest, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(imageDigest)) + { + return null; + } + + var normalizedDigest = imageDigest.Trim(); + + List links; + try + { + links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, normalizedDigest, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load link documents for digest {Digest}.", normalizedDigest); + return null; + } + + if (links.Count == 0) + { + return null; + } + + var options = _optionsMonitor.CurrentValue ?? new ScannerWebServiceOptions(); + var artifactStore = options.ArtifactStore ?? new ScannerWebServiceOptions.ArtifactStoreOptions(); + var bucket = ResolveBucket(artifactStore); + var rootPrefix = artifactStore.RootPrefix ?? ScannerStorageDefaults.DefaultRootPrefix; + var tenant = _surfaceEnvironment.Settings.Tenant; + var generatedAt = _timeProvider.GetUtcNow(); + + var artifacts = ImmutableArray.CreateBuilder(); + + foreach (var link in links) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArtifactDocument? artifactDocument; + try + { + artifactDocument = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load artifact document {ArtifactId}.", link.ArtifactId); + continue; + } + + if (artifactDocument is null) + { + continue; + } + + var objectKey = ArtifactObjectKeyBuilder.Build( + artifactDocument.Type, + artifactDocument.Format, + artifactDocument.BytesSha256, + rootPrefix); + var uri = BuildCasUri(bucket, objectKey); + var (kind, view) = MapKindAndView(artifactDocument); + var format = MapFormat(artifactDocument.Format); + var artifact = new SurfaceManifestArtifact + { + Kind = kind, + Uri = uri, + Digest = artifactDocument.BytesSha256, + MediaType = artifactDocument.MediaType, + Format = format, + SizeBytes = artifactDocument.SizeBytes, + View = view + }; + artifacts.Add(artifact); + } + + if (artifacts.Count == 0) + { + return null; + } + + var orderedArtifacts = artifacts.OrderBy(a => a.Kind, StringComparer.Ordinal) + .ThenBy(a => a.Format, StringComparer.Ordinal) + .ThenBy(a => a.Digest, StringComparer.Ordinal) + .ToImmutableArray(); + + var manifest = new SurfaceManifestDocument + { + Tenant = tenant, + ImageDigest = normalizedDigest, + GeneratedAt = generatedAt, + Artifacts = orderedArtifacts + }; + + var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, ManifestSerializerOptions); + var manifestDigest = ComputeDigest(manifestJson); + var manifestUri = BuildManifestUri(bucket, rootPrefix, tenant, manifestDigest); + + return new SurfacePointersDto + { + Tenant = tenant, + GeneratedAt = generatedAt, + ManifestDigest = manifestDigest, + ManifestUri = manifestUri, + Manifest = manifest with { GeneratedAt = generatedAt } + }; + } + + private static string ResolveBucket(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore) + { + if (!string.IsNullOrWhiteSpace(artifactStore.Bucket)) + { + return artifactStore.Bucket.Trim(); + } + + return ScannerStorageDefaults.DefaultBucketName; + } + + private static string MapFormat(ArtifactDocumentFormat format) + => format switch + { + ArtifactDocumentFormat.CycloneDxJson => "cdx-json", + ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf", + ArtifactDocumentFormat.SpdxJson => "spdx-json", + ArtifactDocumentFormat.BomIndex => "bom-index", + ArtifactDocumentFormat.DsseJson => "dsse-json", + _ => format.ToString().ToLowerInvariant() + }; + + private static (string Kind, string? View) MapKindAndView(ArtifactDocument document) + { + if (document.Type == ArtifactDocumentType.ImageBom) + { + var view = ResolveView(document.MediaType); + var kind = string.Equals(view, "usage", StringComparison.OrdinalIgnoreCase) + ? "sbom-usage" + : "sbom-inventory"; + return (kind, view); + } + + return document.Type switch + { + ArtifactDocumentType.LayerBom => ("layer-sbom", null), + ArtifactDocumentType.Diff => ("diff", null), + ArtifactDocumentType.Attestation => ("attestation", null), + ArtifactDocumentType.Index => ("bom-index", null), + _ => (document.Type.ToString().ToLowerInvariant(), null) + }; + } + + private static string? ResolveView(string mediaType) + { + if (string.IsNullOrWhiteSpace(mediaType)) + { + return null; + } + + if (mediaType.Contains("view=usage", StringComparison.OrdinalIgnoreCase)) + { + return "usage"; + } + + if (mediaType.Contains("view=inventory", StringComparison.OrdinalIgnoreCase)) + { + return "inventory"; + } + + return null; + } + + private static string BuildCasUri(string bucket, string key) + { + var normalizedKey = string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim().TrimStart('/'); + return $"cas://{bucket}/{normalizedKey}"; + } + + private static string BuildManifestUri(string bucket, string rootPrefix, string tenant, string manifestDigest) + { + var (algorithm, digestValue) = SplitDigest(manifestDigest); + var prefix = string.IsNullOrWhiteSpace(rootPrefix) + ? "surface/manifests" + : $"{TrimTrailingSlash(rootPrefix)}/surface/manifests"; + + var head = digestValue.Length >= 4 + ? $"{digestValue[..2]}/{digestValue[2..4]}" + : digestValue; + + var key = $"{prefix}/{tenant}/{algorithm}/{head}/{digestValue}.json"; + return $"cas://{bucket}/{key}"; + } + + private static (string Algorithm, string Digest) SplitDigest(string digest) + { + if (string.IsNullOrWhiteSpace(digest)) + { + return ("sha256", digest ?? string.Empty); + } + + var parts = digest.Split(':', 2, StringSplitOptions.TrimEntries); + if (parts.Length == 2) + { + return (parts[0], parts[1]); + } + + return ("sha256", digest); + } + + private static string TrimTrailingSlash(string value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().TrimEnd('/'); + + private static string ComputeDigest(ReadOnlySpan payload) + { + Span hash = stackalloc byte[32]; + if (!SHA256.TryHashData(payload, hash, out _)) + { + using var sha = SHA256.Create(); + hash = sha.ComputeHash(payload.ToArray()); + } + + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj b/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj index a1406d0b..1c8b124d 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj +++ b/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj @@ -29,6 +29,10 @@ + + + + - \ No newline at end of file + diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index e505b6f7..c2205cfc 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -3,7 +3,7 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| | SCAN-REPLAY-186-001 | TODO | Scanner WebService Guild | REPLAY-CORE-185-001 | Implement scan `record` mode producing replay manifests/bundles, capture policy/feed/tool hashes, and update `docs/modules/scanner/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Section 6. | API/worker integration tests cover record mode; docs merged; replay artifacts stored per spec. | -| SCANNER-SURFACE-02 | DOING (2025-11-02) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.
2025-11-02: Scan/report API responses now include preview CAS URIs; attestation metadata draft published. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. | +| SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.
2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. | | SCANNER-ENV-02 | DOING (2025-11-02) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.
2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review. | Service uses helper; env table documented; helm/compose templates updated. | | SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. | | SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs new file mode 100644 index 00000000..7d88ecea --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs @@ -0,0 +1,75 @@ +using System; +using StellaOps.Scanner.Storage.Catalog; + +namespace StellaOps.Scanner.Storage.ObjectStore; + +/// +/// Builds deterministic object keys for scanner artefacts stored in the backing object store. +/// +public static class ArtifactObjectKeyBuilder +{ + /// + /// Builds an object key for the provided artefact metadata. + /// + /// Artefact type. + /// Artefact format. + /// Content digest (with or without algorithm prefix). + /// Optional root prefix to prepend (defaults to scanner). + /// Deterministic storage key. + public static string Build( + ArtifactDocumentType type, + ArtifactDocumentFormat format, + string digest, + string? rootPrefix = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(digest); + + var normalizedDigest = NormalizeDigest(digest); + var digestValue = ExtractDigest(normalizedDigest); + + var prefix = type switch + { + ArtifactDocumentType.LayerBom => ScannerStorageDefaults.ObjectPrefixes.Layers, + ArtifactDocumentType.ImageBom => ScannerStorageDefaults.ObjectPrefixes.Images, + ArtifactDocumentType.Index => ScannerStorageDefaults.ObjectPrefixes.Indexes, + ArtifactDocumentType.Attestation => ScannerStorageDefaults.ObjectPrefixes.Attestations, + ArtifactDocumentType.Diff => "diffs", + _ => ScannerStorageDefaults.ObjectPrefixes.Images, + }; + + var extension = format switch + { + ArtifactDocumentFormat.CycloneDxJson => "sbom.cdx.json", + ArtifactDocumentFormat.CycloneDxProtobuf => "sbom.cdx.pb", + ArtifactDocumentFormat.SpdxJson => "sbom.spdx.json", + ArtifactDocumentFormat.BomIndex => "bom-index.bin", + ArtifactDocumentFormat.DsseJson => "artifact.dsse.json", + _ => "artifact.bin", + }; + + var key = $"{prefix}/{digestValue}/{extension}"; + + if (string.IsNullOrWhiteSpace(rootPrefix)) + { + return key; + } + + return $"{TrimTrailingSlash(rootPrefix)}/{key}"; + } + + private static string NormalizeDigest(string digest) + => digest.Contains(':', StringComparison.Ordinal) + ? digest.Trim() + : $"sha256:{digest.Trim()}"; + + private static string ExtractDigest(string normalizedDigest) + { + var parts = normalizedDigest.Split(':', 2, StringSplitOptions.TrimEntries); + return parts.Length == 2 ? parts[1] : normalizedDigest; + } + + private static string TrimTrailingSlash(string value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().TrimEnd('/'); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/ArtifactStorageService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/ArtifactStorageService.cs index 75f4e23b..c0868df3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/ArtifactStorageService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/ArtifactStorageService.cs @@ -50,8 +50,12 @@ public sealed class ArtifactStorageService try { var normalizedDigest = $"sha256:{digestHex}"; - var artifactId = CatalogIdFactory.CreateArtifactId(type, normalizedDigest); - var key = BuildObjectKey(type, format, normalizedDigest); + var artifactId = CatalogIdFactory.CreateArtifactId(type, normalizedDigest); + var key = ArtifactObjectKeyBuilder.Build( + type, + format, + normalizedDigest, + _options.ObjectStore.RootPrefix); var descriptor = new ArtifactObjectDescriptor( _options.ObjectStore.BucketName, key, @@ -137,45 +141,4 @@ public sealed class ArtifactStorageService return (bufferStream, total, digestHex); } - private string BuildObjectKey(ArtifactDocumentType type, ArtifactDocumentFormat format, string digest) - { - var normalizedDigest = digest.Split(':', 2, StringSplitOptions.TrimEntries)[^1]; - var prefix = type switch - { - ArtifactDocumentType.LayerBom => ScannerStorageDefaults.ObjectPrefixes.Layers, - ArtifactDocumentType.ImageBom => ScannerStorageDefaults.ObjectPrefixes.Images, - ArtifactDocumentType.Diff => "diffs", - ArtifactDocumentType.Index => ScannerStorageDefaults.ObjectPrefixes.Indexes, - ArtifactDocumentType.Attestation => ScannerStorageDefaults.ObjectPrefixes.Attestations, - _ => ScannerStorageDefaults.ObjectPrefixes.Images, - }; - - var extension = format switch - { - ArtifactDocumentFormat.CycloneDxJson => "sbom.cdx.json", - ArtifactDocumentFormat.CycloneDxProtobuf => "sbom.cdx.pb", - ArtifactDocumentFormat.SpdxJson => "sbom.spdx.json", - ArtifactDocumentFormat.BomIndex => "bom-index.bin", - ArtifactDocumentFormat.DsseJson => "artifact.dsse.json", - _ => "artifact.bin", - }; - - var rootPrefix = _options.ObjectStore.RootPrefix; - if (string.IsNullOrWhiteSpace(rootPrefix)) - { - return $"{prefix}/{normalizedDigest}/{extension}"; - } - - return $"{TrimTrailingSlash(rootPrefix)}/{prefix}/{normalizedDigest}/{extension}"; - } - - private static string TrimTrailingSlash(string prefix) - { - if (string.IsNullOrWhiteSpace(prefix)) - { - return string.Empty; - } - - return prefix.TrimEnd('/'); - } -} +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerApplicationFactory.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerApplicationFactory.cs index 37d117c8..94384db7 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerApplicationFactory.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerApplicationFactory.cs @@ -1,10 +1,13 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Mongo2Go; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Mongo2Go; +using StellaOps.Scanner.Surface.Validation; namespace StellaOps.Scanner.WebService.Tests; @@ -56,14 +59,17 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTID", null); Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTSECRET", null); Environment.SetEnvironmentVariable("SCANNER__STORAGE__DSN", configuration["scanner:storage:dsn"]); - Environment.SetEnvironmentVariable("SCANNER__QUEUE__DSN", configuration["scanner:queue:dsn"]); - Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ENDPOINT", configuration["scanner:artifactStore:endpoint"]); - Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ACCESSKEY", configuration["scanner:artifactStore:accessKey"]); - Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__SECRETKEY", configuration["scanner:artifactStore:secretKey"]); - if (configuration.TryGetValue("scanner:events:enabled", out var eventsEnabled)) - { - Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", eventsEnabled); - } + Environment.SetEnvironmentVariable("SCANNER__QUEUE__DSN", configuration["scanner:queue:dsn"]); + Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ENDPOINT", configuration["scanner:artifactStore:endpoint"]); + Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ACCESSKEY", configuration["scanner:artifactStore:accessKey"]); + Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__SECRETKEY", configuration["scanner:artifactStore:secretKey"]); + Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.local"); + Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", configuration["scanner:artifactStore:bucket"]); + Environment.SetEnvironmentVariable("SCANNER_SURFACE_PREFETCH_ENABLED", "false"); + if (configuration.TryGetValue("scanner:events:enabled", out var eventsEnabled)) + { + Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", eventsEnabled); + } if (configuration.TryGetValue("scanner:authority:enabled", out var authorityEnabled)) { @@ -100,11 +106,13 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory configBuilder.AddInMemoryCollection(configuration); }); - builder.ConfigureTestServices(services => - { - configureServices?.Invoke(services); - }); - } + builder.ConfigureTestServices(services => + { + configureServices?.Invoke(services); + services.RemoveAll(); + services.AddSingleton(); + }); + } protected override void Dispose(bool disposing) { @@ -163,6 +171,19 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory current = parent.FullName; } - return null; - } -} + return null; + } + + private sealed class TestSurfaceValidatorRunner : ISurfaceValidatorRunner + { + public ValueTask RunAllAsync( + SurfaceValidationContext context, + CancellationToken cancellationToken = default) + => ValueTask.FromResult(SurfaceValidationResult.Success()); + + public ValueTask EnsureAsync( + SurfaceValidationContext context, + CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs index c5677af0..fbe4dbd0 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs @@ -54,10 +54,10 @@ public sealed class ScansEndpointsTests Assert.Equal(payload.ScanId, status!.ScanId); Assert.Equal("Pending", status.Status); Assert.Equal("ghcr.io/demo/app:1.0.0", status.Image.Reference); - } - - [Fact] - public async Task SubmitScanIsDeterministicForIdenticalPayloads() + } + + [Fact] + public async Task SubmitScanIsDeterministicForIdenticalPayloads() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); @@ -81,11 +81,98 @@ public sealed class ScansEndpointsTests Assert.Equal(firstPayload!.ScanId, secondPayload!.ScanId); Assert.True(firstPayload.Created); Assert.False(secondPayload.Created); - } - - [Fact] - public async Task SubmitScanValidatesImageDescriptor() - { + } + + [Fact] + public async Task ScanStatusIncludesSurfacePointersWhenArtifactsExist() + { + const string digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + var digestValue = digest.Split(':', 2)[1]; + + using var factory = new ScannerApplicationFactory(); + + using (var scope = factory.Services.CreateScope()) + { + var artifactRepository = scope.ServiceProvider.GetRequiredService(); + var linkRepository = scope.ServiceProvider.GetRequiredService(); + var artifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, digest); + + var artifact = new ArtifactDocument + { + Id = artifactId, + Type = ArtifactDocumentType.ImageBom, + Format = ArtifactDocumentFormat.CycloneDxJson, + MediaType = "application/vnd.cyclonedx+json; version=1.6; view=inventory", + BytesSha256 = digest, + SizeBytes = 2048, + Immutable = true, + RefCount = 1, + TtlClass = "default", + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }; + + await artifactRepository.UpsertAsync(artifact, CancellationToken.None).ConfigureAwait(false); + + var link = new LinkDocument + { + Id = CatalogIdFactory.CreateLinkId(LinkSourceType.Image, digest, artifactId), + FromType = LinkSourceType.Image, + FromDigest = digest, + ArtifactId = artifactId, + CreatedAtUtc = DateTime.UtcNow + }; + + await linkRepository.UpsertAsync(link, CancellationToken.None).ConfigureAwait(false); + } + + using var client = factory.CreateClient(); + + var submitRequest = new ScanSubmitRequest + { + Image = new ScanImageDescriptor + { + Digest = digest + } + }; + + var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", submitRequest); + submitResponse.EnsureSuccessStatusCode(); + + var submission = await submitResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(submission); + + var statusResponse = await client.GetAsync($"/api/v1/scans/{submission!.ScanId}"); + statusResponse.EnsureSuccessStatusCode(); + + var status = await statusResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(status); + Assert.NotNull(status!.Surface); + + var surface = status.Surface!; + Assert.Equal("default", surface.Tenant); + Assert.False(string.IsNullOrWhiteSpace(surface.ManifestDigest)); + Assert.NotNull(surface.ManifestUri); + Assert.Contains("cas://scanner-artifacts/", surface.ManifestUri, StringComparison.Ordinal); + + var manifest = surface.Manifest; + Assert.Equal(digest, manifest.ImageDigest); + Assert.Equal(surface.Tenant, manifest.Tenant); + Assert.NotEqual(default, manifest.GeneratedAt); + var manifestArtifact = Assert.Single(manifest.Artifacts); + Assert.Equal("sbom-inventory", manifestArtifact.Kind); + Assert.Equal("cdx-json", manifestArtifact.Format); + Assert.Equal(digest, manifestArtifact.Digest); + Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=inventory", manifestArtifact.MediaType); + Assert.Equal("inventory", manifestArtifact.View); + + var expectedUri = $"cas://scanner-artifacts/scanner/images/{digestValue}/sbom.cdx.json"; + Assert.Equal(expectedUri, manifestArtifact.Uri); + } + + [Fact] + public async Task SubmitScanValidatesImageDescriptor() + { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); @@ -462,7 +549,7 @@ public sealed class ScansEndpointsTests var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson); using var factory = new ScannerApplicationFactory( - configuration: null, + configureConfiguration: null, services => { services.AddSingleton(new StubEntryTraceResultStore(storedResult)); @@ -485,7 +572,7 @@ public sealed class ScansEndpointsTests public async Task GetEntryTraceReturnsNotFoundWhenMissing() { using var factory = new ScannerApplicationFactory( - configuration: null, + configureConfiguration: null, services => { services.AddSingleton(new StubEntryTraceResultStore(null)); diff --git a/src/Scheduler/StellaOps.Scheduler.WebService/GraphJobs/GraphJobUpdateResult.cs b/src/Scheduler/StellaOps.Scheduler.WebService/GraphJobs/GraphJobUpdateResult.cs index 47db2038..697a7cfc 100644 --- a/src/Scheduler/StellaOps.Scheduler.WebService/GraphJobs/GraphJobUpdateResult.cs +++ b/src/Scheduler/StellaOps.Scheduler.WebService/GraphJobs/GraphJobUpdateResult.cs @@ -1,8 +1,8 @@ -namespace StellaOps.Scheduler.WebService.GraphJobs; - -internal readonly record struct GraphJobUpdateResult(bool Updated, TJob Job) where TJob : class -{ - public static GraphJobUpdateResult UpdatedResult(TJob job) => new(true, job); - - public static GraphJobUpdateResult NotUpdated(TJob job) => new(false, job); -} +namespace StellaOps.Scheduler.WebService.GraphJobs; + +internal readonly record struct GraphJobUpdateResult(bool Updated, TJob Job) where TJob : class +{ + public static GraphJobUpdateResult UpdatedResult(TJob job) => new(true, job); + + public static GraphJobUpdateResult NotUpdated(TJob job) => new(false, job); +} diff --git a/src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md b/src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md index 61a465b2..7719339e 100644 --- a/src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md +++ b/src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md @@ -1,42 +1,42 @@ -# Scheduler WebService Task Board (Sprint 16) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| - -## Policy Engine v2 (Sprint 20) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Added `/api/v1/scheduler/policy/runs` create/list/get endpoints with in-memory queue, scope/tenant enforcement, and contract docs (`docs/SCHED-WEB-20-001-POLICY-RUNS.md`). Tests cover happy path + auth failures. -> 2025-10-26: Use canonical request/response samples from `samples/api/scheduler/policy-*.json`; serializer contract defined in `src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`. -| SCHED-WEB-20-002 | BLOCKED (waiting on SCHED-WORKER-20-301) | Scheduler WebService Guild | SCHED-WEB-20-001, SCHED-WORKER-20-301 | Provide simulation trigger endpoint returning diff preview metadata and job state for UI/CLI consumption. | Simulation endpoint returns deterministic diffs metadata; rate limits enforced; tests cover concurrency. | -> 2025-10-29: WebService requires Worker policy job orchestration + Policy Engine diff callbacks (POLICY-ENGINE-20-003/006) to provide simulation previews. Awaiting completion of SCHED-WORKER-20-301 before wiring API. - -## Graph Explorer v1 (Sprint 21) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| SCHED-WEB-21-004 | DONE (2025-11-04) | Scheduler WebService Guild, Scheduler Storage Guild | SCHED-WEB-21-001, SCHED-STORAGE-16-201 | Persist graph job lifecycle to Mongo storage and publish `scheduler.graph.job.completed@1` events + outbound webhook to Cartographer. | Storage repositories updated; events emitted; webhook payload documented; integration tests cover storage + event flow. **Note:** Events currently log JSON envelopes while the shared platform bus is provisioned. Cartographer webhook now posts JSON payloads when configured; replace inline logging with bus publisher once the shared event transport is online. | -> 2025-10-30: Implemented Redis-backed publisher (`Scheduler:Events:GraphJobs`) emitting `scheduler.graph.job.completed@1` to configured stream with optional logging fallback; docs/configs to be validated with DevOps before closing. -> 2025-11-04: Resumed SCHED-WEB-21-004 to finalize Mongo lifecycle persistence guards, graph completion events, and Cartographer webhook verification. -> 2025-11-04: SCHED-WEB-21-004 completed – lifecycle stored in Mongo with optimistic concurrency, completion events/webhooks emitted once per transition, and result URI metadata refreshed idempotently with unit/integration coverage. - -## StellaOps Console (Sprint 23) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| SCHED-CONSOLE-23-001 | DONE (2025-11-03) | Scheduler WebService Guild, BE-Base Platform Guild | SCHED-WEB-16-103, SCHED-WEB-20-001 | Extend runs APIs with live progress SSE endpoints (`/console/runs/{id}/stream`), queue lag summaries, diff metadata fetch, retry/cancel hooks with RBAC enforcement, and deterministic pagination for history views consumed by Console. | SSE emits heartbeats/backoff headers, progress payload schema documented, unauthorized actions blocked in integration tests, metrics/logs expose queue lag + correlation IDs. | - -## Policy Studio (Sprint 27) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| SCHED-CONSOLE-27-001 | DONE (2025-11-03) | Scheduler WebService Guild, Policy Registry Guild | SCHED-WEB-16-103, REGISTRY-API-27-005 | Provide policy batch simulation orchestration endpoints (`/policies/simulations` POST/GET) exposing run creation, shard status, SSE progress, cancellation, and retries with RBAC enforcement. | API handles shard lifecycle with SSE heartbeats + retry headers; unauthorized requests rejected; integration tests cover submit/cancel/resume flows. | -| SCHED-CONSOLE-27-002 | DOING (2025-11-03) | Scheduler WebService Guild, Observability Guild | SCHED-CONSOLE-27-001 | Emit telemetry endpoints/metrics (`policy_simulation_queue_depth`, `policy_simulation_latency`) and webhook callbacks for completion/failure consumed by Registry. | Metrics exposed via gateway, dashboards seeded, webhook contract documented, integration tests validate metrics emission. | - -## Vulnerability Explorer (Sprint 29) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| SCHED-VULN-29-001 | TODO | Scheduler WebService Guild, Findings Ledger Guild | SCHED-WEB-16-103, SBOM-VULN-29-001 | Expose resolver job APIs (`POST /vuln/resolver/jobs`, `GET /vuln/resolver/jobs/{id}`) to trigger candidate recomputation per artifact/policy change with RBAC and rate limits. | Resolver APIs documented; integration tests cover submit/status/cancel; unauthorized requests rejected. | -| SCHED-VULN-29-002 | TODO | Scheduler WebService Guild, Observability Guild | SCHED-VULN-29-001 | Provide projector lag metrics endpoint and webhook notifications for backlog breaches consumed by DevOps dashboards. | Lag metrics exposed; webhook events triggered on thresholds; docs updated. | - -## Notes -- 2025-10-27: Minimal API host now wires Authority, health endpoints, and restart-only plug-in discovery per architecture §§1–2. +# Scheduler WebService Task Board (Sprint 16) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| + +## Policy Engine v2 (Sprint 20) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Added `/api/v1/scheduler/policy/runs` create/list/get endpoints with in-memory queue, scope/tenant enforcement, and contract docs (`docs/SCHED-WEB-20-001-POLICY-RUNS.md`). Tests cover happy path + auth failures. +> 2025-10-26: Use canonical request/response samples from `samples/api/scheduler/policy-*.json`; serializer contract defined in `src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`. +| SCHED-WEB-20-002 | BLOCKED (waiting on SCHED-WORKER-20-301) | Scheduler WebService Guild | SCHED-WEB-20-001, SCHED-WORKER-20-301 | Provide simulation trigger endpoint returning diff preview metadata and job state for UI/CLI consumption. | Simulation endpoint returns deterministic diffs metadata; rate limits enforced; tests cover concurrency. | +> 2025-10-29: WebService requires Worker policy job orchestration + Policy Engine diff callbacks (POLICY-ENGINE-20-003/006) to provide simulation previews. Awaiting completion of SCHED-WORKER-20-301 before wiring API. + +## Graph Explorer v1 (Sprint 21) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| SCHED-WEB-21-004 | DONE (2025-11-04) | Scheduler WebService Guild, Scheduler Storage Guild | SCHED-WEB-21-001, SCHED-STORAGE-16-201 | Persist graph job lifecycle to Mongo storage and publish `scheduler.graph.job.completed@1` events + outbound webhook to Cartographer. | Storage repositories updated; events emitted; webhook payload documented; integration tests cover storage + event flow. **Note:** Events currently log JSON envelopes while the shared platform bus is provisioned. Cartographer webhook now posts JSON payloads when configured; replace inline logging with bus publisher once the shared event transport is online. | +> 2025-10-30: Implemented Redis-backed publisher (`Scheduler:Events:GraphJobs`) emitting `scheduler.graph.job.completed@1` to configured stream with optional logging fallback; docs/configs to be validated with DevOps before closing. +> 2025-11-04: Resumed SCHED-WEB-21-004 to finalize Mongo lifecycle persistence guards, graph completion events, and Cartographer webhook verification. +> 2025-11-04: SCHED-WEB-21-004 completed – lifecycle stored in Mongo with optimistic concurrency, completion events/webhooks emitted once per transition, and result URI metadata refreshed idempotently with unit/integration coverage. + +## StellaOps Console (Sprint 23) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| SCHED-CONSOLE-23-001 | DONE (2025-11-03) | Scheduler WebService Guild, BE-Base Platform Guild | SCHED-WEB-16-103, SCHED-WEB-20-001 | Extend runs APIs with live progress SSE endpoints (`/console/runs/{id}/stream`), queue lag summaries, diff metadata fetch, retry/cancel hooks with RBAC enforcement, and deterministic pagination for history views consumed by Console. | SSE emits heartbeats/backoff headers, progress payload schema documented, unauthorized actions blocked in integration tests, metrics/logs expose queue lag + correlation IDs. | + +## Policy Studio (Sprint 27) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| SCHED-CONSOLE-27-001 | DONE (2025-11-03) | Scheduler WebService Guild, Policy Registry Guild | SCHED-WEB-16-103, REGISTRY-API-27-005 | Provide policy batch simulation orchestration endpoints (`/policies/simulations` POST/GET) exposing run creation, shard status, SSE progress, cancellation, and retries with RBAC enforcement. | API handles shard lifecycle with SSE heartbeats + retry headers; unauthorized requests rejected; integration tests cover submit/cancel/resume flows. | +| SCHED-CONSOLE-27-002 | DOING (2025-11-03) | Scheduler WebService Guild, Observability Guild | SCHED-CONSOLE-27-001 | Emit telemetry endpoints/metrics (`policy_simulation_queue_depth`, `policy_simulation_latency`) and webhook callbacks for completion/failure consumed by Registry. | Metrics exposed via gateway, dashboards seeded, webhook contract documented, integration tests validate metrics emission. | + +## Vulnerability Explorer (Sprint 29) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| SCHED-VULN-29-001 | TODO | Scheduler WebService Guild, Findings Ledger Guild | SCHED-WEB-16-103, SBOM-VULN-29-001 | Expose resolver job APIs (`POST /vuln/resolver/jobs`, `GET /vuln/resolver/jobs/{id}`) to trigger candidate recomputation per artifact/policy change with RBAC and rate limits. | Resolver APIs documented; integration tests cover submit/status/cancel; unauthorized requests rejected. | +| SCHED-VULN-29-002 | TODO | Scheduler WebService Guild, Observability Guild | SCHED-VULN-29-001 | Provide projector lag metrics endpoint and webhook notifications for backlog breaches consumed by DevOps dashboards. | Lag metrics exposed; webhook events triggered on thresholds; docs updated. | + +## Notes +- 2025-10-27: Minimal API host now wires Authority, health endpoints, and restart-only plug-in discovery per architecture §§1–2. diff --git a/src/Scheduler/StellaOps.Scheduler.WebService/docs/SCHED-WEB-16-103-RUN-APIS.md b/src/Scheduler/StellaOps.Scheduler.WebService/docs/SCHED-WEB-16-103-RUN-APIS.md deleted file mode 100644 index 45c38186..00000000 --- a/src/Scheduler/StellaOps.Scheduler.WebService/docs/SCHED-WEB-16-103-RUN-APIS.md +++ /dev/null @@ -1,12 +0,0 @@ - -## Policy simulations - -`/api/v1/scheduler/policies/simulations` orchestrates Policy Engine runs in `simulate` mode without mutating persisted findings. - -- **Create** — `POST /api/v1/scheduler/policies/simulations` (scope `policy:simulate`) enqueues a simulation for `policyId`/`policyVersion`, respecting optional `metadata` and structured `inputs` (`sbomSet`, `advisoryCursor`, `vexCursor`, `captureExplain`). Returns `201 Created` with `simulation.runId` and status `queued`. -- **List/Get** — `GET /api/v1/scheduler/policies/simulations` and `/.../{simulationId}` expose `PolicyRunStatus` documents filtered to `mode=simulate`, including attempt counts, stats, and cancellation markers. -- **Cancel** — `POST /.../{simulationId}/cancel` records `cancellationRequested=true` (optional reason, timestamp) and immediately reflects the updated status; workers honour the flag on the next lease cycle. -- **Retry** — `POST /.../{simulationId}/retry` clones a terminal simulation (cancelled/failed/succeeded) into a fresh job preserving inputs/metadata. Non-terminal runs yield `409 Conflict`. -- **Stream** — `GET /.../{simulationId}/stream` emits SSE events (`initial`, `status`, `queueLag`, `heartbeat`, `completed`) with the latest `PolicyRunStatus`, enabling Console to render shard progress and cancellation state in real time. - -Simulation APIs share the same deterministic pagination/metadata contracts as policy runs and surface queue depth snapshots via the existing scheduler queue metrics. diff --git a/src/Scheduler/__Tests/StellaOps.Scheduler.Storage.Mongo.Tests/Integration/GraphJobStoreTests.cs b/src/Scheduler/__Tests/StellaOps.Scheduler.Storage.Mongo.Tests/Integration/GraphJobStoreTests.cs index 0e7bf9e8..e38ff160 100644 --- a/src/Scheduler/__Tests/StellaOps.Scheduler.Storage.Mongo.Tests/Integration/GraphJobStoreTests.cs +++ b/src/Scheduler/__Tests/StellaOps.Scheduler.Storage.Mongo.Tests/Integration/GraphJobStoreTests.cs @@ -1,70 +1,70 @@ -using System.Threading; -using System.Threading.Tasks; -using StellaOps.Scheduler.Models; -using StellaOps.Scheduler.Storage.Mongo.Repositories; -using StellaOps.Scheduler.WebService.GraphJobs; -using Xunit; - -namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration; - -public sealed class GraphJobStoreTests -{ - private static readonly DateTimeOffset OccurredAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero); - - [Fact] - public async Task UpdateAsync_SucceedsWhenExpectedStatusMatches() - { - using var harness = new SchedulerMongoTestHarness(); - var repository = new GraphJobRepository(harness.Context); - var store = new MongoGraphJobStore(repository); - - var initial = CreateBuildJob(); - await store.AddAsync(initial, CancellationToken.None); - - var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts); - var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1); - - var updateResult = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); - - Assert.True(updateResult.Updated); - var persisted = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); - Assert.NotNull(persisted); - Assert.Equal(GraphJobStatus.Completed, persisted!.Status); - } - - [Fact] - public async Task UpdateAsync_ReturnsExistingWhenExpectedStatusMismatch() - { - using var harness = new SchedulerMongoTestHarness(); - var repository = new GraphJobRepository(harness.Context); - var store = new MongoGraphJobStore(repository); - - var initial = CreateBuildJob(); - await store.AddAsync(initial, CancellationToken.None); - - var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts); - var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1); - - await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); - - var result = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); - - Assert.False(result.Updated); - Assert.Equal(GraphJobStatus.Completed, result.Job.Status); - } - - private static GraphBuildJob CreateBuildJob() - { - var digest = "sha256:" + new string('b', 64); - return new GraphBuildJob( - id: "gbj_store_test", - tenantId: "tenant-store", - sbomId: "sbom-alpha", - sbomVersionId: "sbom-alpha-v1", - sbomDigest: digest, - status: GraphJobStatus.Pending, - trigger: GraphBuildJobTrigger.SbomVersion, - createdAt: OccurredAt, - metadata: null); - } -} +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Scheduler.Models; +using StellaOps.Scheduler.Storage.Mongo.Repositories; +using StellaOps.Scheduler.WebService.GraphJobs; +using Xunit; + +namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration; + +public sealed class GraphJobStoreTests +{ + private static readonly DateTimeOffset OccurredAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero); + + [Fact] + public async Task UpdateAsync_SucceedsWhenExpectedStatusMatches() + { + using var harness = new SchedulerMongoTestHarness(); + var repository = new GraphJobRepository(harness.Context); + var store = new MongoGraphJobStore(repository); + + var initial = CreateBuildJob(); + await store.AddAsync(initial, CancellationToken.None); + + var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts); + var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1); + + var updateResult = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); + + Assert.True(updateResult.Updated); + var persisted = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); + Assert.NotNull(persisted); + Assert.Equal(GraphJobStatus.Completed, persisted!.Status); + } + + [Fact] + public async Task UpdateAsync_ReturnsExistingWhenExpectedStatusMismatch() + { + using var harness = new SchedulerMongoTestHarness(); + var repository = new GraphJobRepository(harness.Context); + var store = new MongoGraphJobStore(repository); + + var initial = CreateBuildJob(); + await store.AddAsync(initial, CancellationToken.None); + + var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts); + var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1); + + await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); + + var result = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None); + + Assert.False(result.Updated); + Assert.Equal(GraphJobStatus.Completed, result.Job.Status); + } + + private static GraphBuildJob CreateBuildJob() + { + var digest = "sha256:" + new string('b', 64); + return new GraphBuildJob( + id: "gbj_store_test", + tenantId: "tenant-store", + sbomId: "sbom-alpha", + sbomVersionId: "sbom-alpha-v1", + sbomDigest: digest, + status: GraphJobStatus.Pending, + trigger: GraphBuildJobTrigger.SbomVersion, + createdAt: OccurredAt, + metadata: null); + } +} diff --git a/src/Scheduler/__Tests/StellaOps.Scheduler.WebService.Tests/GraphJobServiceTests.cs b/src/Scheduler/__Tests/StellaOps.Scheduler.WebService.Tests/GraphJobServiceTests.cs index 7f4471ae..ddcaf371 100644 --- a/src/Scheduler/__Tests/StellaOps.Scheduler.WebService.Tests/GraphJobServiceTests.cs +++ b/src/Scheduler/__Tests/StellaOps.Scheduler.WebService.Tests/GraphJobServiceTests.cs @@ -1,218 +1,218 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using StellaOps.Scheduler.Models; -using StellaOps.Scheduler.WebService.GraphJobs; -using Xunit; - -namespace StellaOps.Scheduler.WebService.Tests; - -public sealed class GraphJobServiceTests -{ - private static readonly DateTimeOffset FixedTime = new(2025, 11, 4, 12, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task CompleteBuildJob_PersistsMetadataAndPublishesOnce() - { - var store = new TrackingGraphJobStore(); - var initial = CreateBuildJob(); - await store.AddAsync(initial, CancellationToken.None); - - var clock = new FixedClock(FixedTime); - var publisher = new RecordingPublisher(); - var webhook = new RecordingWebhookClient(); - var service = new GraphJobService(store, clock, publisher, webhook); - - var request = new GraphJobCompletionRequest - { - JobId = initial.Id, - JobType = GraphJobQueryType.Build, - Status = GraphJobStatus.Completed, - OccurredAt = FixedTime, - GraphSnapshotId = "graph_snap_final ", - ResultUri = "oras://cartographer/bundle ", - CorrelationId = "corr-123 " - }; - - var response = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); - - Assert.Equal(GraphJobStatus.Completed, response.Status); - Assert.Equal(1, store.BuildUpdateCount); - Assert.Single(publisher.Notifications); - Assert.Single(webhook.Notifications); - - var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); - Assert.NotNull(stored); - Assert.Equal("graph_snap_final", stored!.GraphSnapshotId); - Assert.Equal("corr-123", stored.CorrelationId); - Assert.True(stored.Metadata.TryGetValue("resultUri", out var resultUri)); - Assert.Equal("oras://cartographer/bundle", resultUri); - } - - [Fact] - public async Task CompleteBuildJob_IsIdempotentWhenAlreadyCompleted() - { - var store = new TrackingGraphJobStore(); - var initial = CreateBuildJob(); - await store.AddAsync(initial, CancellationToken.None); - - var clock = new FixedClock(FixedTime); - var publisher = new RecordingPublisher(); - var webhook = new RecordingWebhookClient(); - var service = new GraphJobService(store, clock, publisher, webhook); - - var request = new GraphJobCompletionRequest - { - JobId = initial.Id, - JobType = GraphJobQueryType.Build, - Status = GraphJobStatus.Completed, - OccurredAt = FixedTime, - GraphSnapshotId = "graph_snap_final", - ResultUri = "oras://cartographer/bundle", - CorrelationId = "corr-123" - }; - - await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); - var updateCountAfterFirst = store.BuildUpdateCount; - - var secondResponse = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); - - Assert.Equal(GraphJobStatus.Completed, secondResponse.Status); - Assert.Equal(updateCountAfterFirst, store.BuildUpdateCount); - Assert.Single(publisher.Notifications); - Assert.Single(webhook.Notifications); - } - - [Fact] - public async Task CompleteBuildJob_UpdatesResultUriWithoutReemittingEvent() - { - var store = new TrackingGraphJobStore(); - var initial = CreateBuildJob(); - await store.AddAsync(initial, CancellationToken.None); - - var clock = new FixedClock(FixedTime); - var publisher = new RecordingPublisher(); - var webhook = new RecordingWebhookClient(); - var service = new GraphJobService(store, clock, publisher, webhook); - - var firstRequest = new GraphJobCompletionRequest - { - JobId = initial.Id, - JobType = GraphJobQueryType.Build, - Status = GraphJobStatus.Completed, - OccurredAt = FixedTime, - GraphSnapshotId = "graph_snap_final", - ResultUri = null, - CorrelationId = "corr-123" - }; - - await service.CompleteJobAsync(initial.TenantId, firstRequest, CancellationToken.None); - Assert.Equal(1, store.BuildUpdateCount); - Assert.Single(publisher.Notifications); - Assert.Single(webhook.Notifications); - - var secondRequest = firstRequest with - { - ResultUri = "oras://cartographer/bundle-v2", - OccurredAt = FixedTime.AddSeconds(30) - }; - - var response = await service.CompleteJobAsync(initial.TenantId, secondRequest, CancellationToken.None); - - Assert.Equal(GraphJobStatus.Completed, response.Status); - Assert.Equal(2, store.BuildUpdateCount); - Assert.Single(publisher.Notifications); - Assert.Single(webhook.Notifications); - - var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); - Assert.NotNull(stored); - Assert.True(stored!.Metadata.TryGetValue("resultUri", out var resultUri)); - Assert.Equal("oras://cartographer/bundle-v2", resultUri); - } - - private static GraphBuildJob CreateBuildJob() - { - var digest = "sha256:" + new string('a', 64); - return new GraphBuildJob( - id: "gbj_test", - tenantId: "tenant-alpha", - sbomId: "sbom-alpha", - sbomVersionId: "sbom-alpha-v1", - sbomDigest: digest, - status: GraphJobStatus.Pending, - trigger: GraphBuildJobTrigger.SbomVersion, - createdAt: FixedTime, - metadata: null); - } - - private sealed class TrackingGraphJobStore : IGraphJobStore - { - private readonly InMemoryGraphJobStore _inner = new(); - - public int BuildUpdateCount { get; private set; } - - public int OverlayUpdateCount { get; private set; } - - public ValueTask AddAsync(GraphBuildJob job, CancellationToken cancellationToken) - => _inner.AddAsync(job, cancellationToken); - - public ValueTask AddAsync(GraphOverlayJob job, CancellationToken cancellationToken) - => _inner.AddAsync(job, cancellationToken); - - public ValueTask GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken) - => _inner.GetJobsAsync(tenantId, query, cancellationToken); - - public ValueTask GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) - => _inner.GetBuildJobAsync(tenantId, jobId, cancellationToken); - - public ValueTask GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) - => _inner.GetOverlayJobAsync(tenantId, jobId, cancellationToken); - - public async ValueTask> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) - { - BuildUpdateCount++; - return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); - } - - public async ValueTask> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) - { - OverlayUpdateCount++; - return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); - } - - public ValueTask> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken) - => _inner.GetOverlayJobsAsync(tenantId, cancellationToken); - } - - private sealed class RecordingPublisher : IGraphJobCompletionPublisher - { - public List Notifications { get; } = new(); - - public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) - { - Notifications.Add(notification); - return Task.CompletedTask; - } - } - - private sealed class RecordingWebhookClient : ICartographerWebhookClient - { - public List Notifications { get; } = new(); - - public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) - { - Notifications.Add(notification); - return Task.CompletedTask; - } - } - - private sealed class FixedClock : ISystemClock - { - public FixedClock(DateTimeOffset utcNow) - { - UtcNow = utcNow; - } - - public DateTimeOffset UtcNow { get; set; } - } -} +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Scheduler.Models; +using StellaOps.Scheduler.WebService.GraphJobs; +using Xunit; + +namespace StellaOps.Scheduler.WebService.Tests; + +public sealed class GraphJobServiceTests +{ + private static readonly DateTimeOffset FixedTime = new(2025, 11, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task CompleteBuildJob_PersistsMetadataAndPublishesOnce() + { + var store = new TrackingGraphJobStore(); + var initial = CreateBuildJob(); + await store.AddAsync(initial, CancellationToken.None); + + var clock = new FixedClock(FixedTime); + var publisher = new RecordingPublisher(); + var webhook = new RecordingWebhookClient(); + var service = new GraphJobService(store, clock, publisher, webhook); + + var request = new GraphJobCompletionRequest + { + JobId = initial.Id, + JobType = GraphJobQueryType.Build, + Status = GraphJobStatus.Completed, + OccurredAt = FixedTime, + GraphSnapshotId = "graph_snap_final ", + ResultUri = "oras://cartographer/bundle ", + CorrelationId = "corr-123 " + }; + + var response = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); + + Assert.Equal(GraphJobStatus.Completed, response.Status); + Assert.Equal(1, store.BuildUpdateCount); + Assert.Single(publisher.Notifications); + Assert.Single(webhook.Notifications); + + var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); + Assert.NotNull(stored); + Assert.Equal("graph_snap_final", stored!.GraphSnapshotId); + Assert.Equal("corr-123", stored.CorrelationId); + Assert.True(stored.Metadata.TryGetValue("resultUri", out var resultUri)); + Assert.Equal("oras://cartographer/bundle", resultUri); + } + + [Fact] + public async Task CompleteBuildJob_IsIdempotentWhenAlreadyCompleted() + { + var store = new TrackingGraphJobStore(); + var initial = CreateBuildJob(); + await store.AddAsync(initial, CancellationToken.None); + + var clock = new FixedClock(FixedTime); + var publisher = new RecordingPublisher(); + var webhook = new RecordingWebhookClient(); + var service = new GraphJobService(store, clock, publisher, webhook); + + var request = new GraphJobCompletionRequest + { + JobId = initial.Id, + JobType = GraphJobQueryType.Build, + Status = GraphJobStatus.Completed, + OccurredAt = FixedTime, + GraphSnapshotId = "graph_snap_final", + ResultUri = "oras://cartographer/bundle", + CorrelationId = "corr-123" + }; + + await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); + var updateCountAfterFirst = store.BuildUpdateCount; + + var secondResponse = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); + + Assert.Equal(GraphJobStatus.Completed, secondResponse.Status); + Assert.Equal(updateCountAfterFirst, store.BuildUpdateCount); + Assert.Single(publisher.Notifications); + Assert.Single(webhook.Notifications); + } + + [Fact] + public async Task CompleteBuildJob_UpdatesResultUriWithoutReemittingEvent() + { + var store = new TrackingGraphJobStore(); + var initial = CreateBuildJob(); + await store.AddAsync(initial, CancellationToken.None); + + var clock = new FixedClock(FixedTime); + var publisher = new RecordingPublisher(); + var webhook = new RecordingWebhookClient(); + var service = new GraphJobService(store, clock, publisher, webhook); + + var firstRequest = new GraphJobCompletionRequest + { + JobId = initial.Id, + JobType = GraphJobQueryType.Build, + Status = GraphJobStatus.Completed, + OccurredAt = FixedTime, + GraphSnapshotId = "graph_snap_final", + ResultUri = null, + CorrelationId = "corr-123" + }; + + await service.CompleteJobAsync(initial.TenantId, firstRequest, CancellationToken.None); + Assert.Equal(1, store.BuildUpdateCount); + Assert.Single(publisher.Notifications); + Assert.Single(webhook.Notifications); + + var secondRequest = firstRequest with + { + ResultUri = "oras://cartographer/bundle-v2", + OccurredAt = FixedTime.AddSeconds(30) + }; + + var response = await service.CompleteJobAsync(initial.TenantId, secondRequest, CancellationToken.None); + + Assert.Equal(GraphJobStatus.Completed, response.Status); + Assert.Equal(2, store.BuildUpdateCount); + Assert.Single(publisher.Notifications); + Assert.Single(webhook.Notifications); + + var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); + Assert.NotNull(stored); + Assert.True(stored!.Metadata.TryGetValue("resultUri", out var resultUri)); + Assert.Equal("oras://cartographer/bundle-v2", resultUri); + } + + private static GraphBuildJob CreateBuildJob() + { + var digest = "sha256:" + new string('a', 64); + return new GraphBuildJob( + id: "gbj_test", + tenantId: "tenant-alpha", + sbomId: "sbom-alpha", + sbomVersionId: "sbom-alpha-v1", + sbomDigest: digest, + status: GraphJobStatus.Pending, + trigger: GraphBuildJobTrigger.SbomVersion, + createdAt: FixedTime, + metadata: null); + } + + private sealed class TrackingGraphJobStore : IGraphJobStore + { + private readonly InMemoryGraphJobStore _inner = new(); + + public int BuildUpdateCount { get; private set; } + + public int OverlayUpdateCount { get; private set; } + + public ValueTask AddAsync(GraphBuildJob job, CancellationToken cancellationToken) + => _inner.AddAsync(job, cancellationToken); + + public ValueTask AddAsync(GraphOverlayJob job, CancellationToken cancellationToken) + => _inner.AddAsync(job, cancellationToken); + + public ValueTask GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken) + => _inner.GetJobsAsync(tenantId, query, cancellationToken); + + public ValueTask GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) + => _inner.GetBuildJobAsync(tenantId, jobId, cancellationToken); + + public ValueTask GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) + => _inner.GetOverlayJobAsync(tenantId, jobId, cancellationToken); + + public async ValueTask> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) + { + BuildUpdateCount++; + return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); + } + + public async ValueTask> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) + { + OverlayUpdateCount++; + return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); + } + + public ValueTask> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken) + => _inner.GetOverlayJobsAsync(tenantId, cancellationToken); + } + + private sealed class RecordingPublisher : IGraphJobCompletionPublisher + { + public List Notifications { get; } = new(); + + public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) + { + Notifications.Add(notification); + return Task.CompletedTask; + } + } + + private sealed class RecordingWebhookClient : ICartographerWebhookClient + { + public List Notifications { get; } = new(); + + public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) + { + Notifications.Add(notification); + return Task.CompletedTask; + } + } + + private sealed class FixedClock : ISystemClock + { + public FixedClock(DateTimeOffset utcNow) + { + UtcNow = utcNow; + } + + public DateTimeOffset UtcNow { get; set; } + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs index c971fb41..e1948b44 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs @@ -1,13 +1,13 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunStepExecutor -{ - Task ExecuteAsync( - PackRunExecutionStep step, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken); -} - -public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null); +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution; + +public interface IPackRunStepExecutor +{ + Task ExecuteAsync( + PackRunExecutionStep step, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken); +} + +public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null); diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs index 5b573176..312ebc7d 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs @@ -1,86 +1,86 @@ -using System.Collections.ObjectModel; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunExecutionGraph -{ - public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false); - - public PackRunExecutionGraph(IReadOnlyList steps, TaskPackPlanFailurePolicy? failurePolicy) - { - Steps = steps ?? throw new ArgumentNullException(nameof(steps)); - FailurePolicy = failurePolicy ?? DefaultFailurePolicy; - } - - public IReadOnlyList Steps { get; } - - public TaskPackPlanFailurePolicy FailurePolicy { get; } -} - -public enum PackRunStepKind -{ - Unknown = 0, - Run, - GateApproval, - GatePolicy, - Parallel, - Map -} - -public sealed class PackRunExecutionStep -{ - public PackRunExecutionStep( - string id, - string templateId, - PackRunStepKind kind, - bool enabled, - string? uses, - IReadOnlyDictionary parameters, - string? approvalId, - string? gateMessage, - int? maxParallel, - bool continueOnError, - IReadOnlyList children) - { - Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; - TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; - Kind = kind; - Enabled = enabled; - Uses = uses; - Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); - ApprovalId = approvalId; - GateMessage = gateMessage; - MaxParallel = maxParallel; - ContinueOnError = continueOnError; - Children = children ?? throw new ArgumentNullException(nameof(children)); - } - - public string Id { get; } - - public string TemplateId { get; } - - public PackRunStepKind Kind { get; } - - public bool Enabled { get; } - - public string? Uses { get; } - - public IReadOnlyDictionary Parameters { get; } - - public string? ApprovalId { get; } - - public string? GateMessage { get; } - - public int? MaxParallel { get; } - - public bool ContinueOnError { get; } - - public IReadOnlyList Children { get; } - - public static IReadOnlyDictionary EmptyParameters { get; } = - new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); - - public static IReadOnlyList EmptyChildren { get; } = - Array.Empty(); -} +using System.Collections.ObjectModel; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution; + +public sealed class PackRunExecutionGraph +{ + public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false); + + public PackRunExecutionGraph(IReadOnlyList steps, TaskPackPlanFailurePolicy? failurePolicy) + { + Steps = steps ?? throw new ArgumentNullException(nameof(steps)); + FailurePolicy = failurePolicy ?? DefaultFailurePolicy; + } + + public IReadOnlyList Steps { get; } + + public TaskPackPlanFailurePolicy FailurePolicy { get; } +} + +public enum PackRunStepKind +{ + Unknown = 0, + Run, + GateApproval, + GatePolicy, + Parallel, + Map +} + +public sealed class PackRunExecutionStep +{ + public PackRunExecutionStep( + string id, + string templateId, + PackRunStepKind kind, + bool enabled, + string? uses, + IReadOnlyDictionary parameters, + string? approvalId, + string? gateMessage, + int? maxParallel, + bool continueOnError, + IReadOnlyList children) + { + Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; + TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; + Kind = kind; + Enabled = enabled; + Uses = uses; + Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); + ApprovalId = approvalId; + GateMessage = gateMessage; + MaxParallel = maxParallel; + ContinueOnError = continueOnError; + Children = children ?? throw new ArgumentNullException(nameof(children)); + } + + public string Id { get; } + + public string TemplateId { get; } + + public PackRunStepKind Kind { get; } + + public bool Enabled { get; } + + public string? Uses { get; } + + public IReadOnlyDictionary Parameters { get; } + + public string? ApprovalId { get; } + + public string? GateMessage { get; } + + public int? MaxParallel { get; } + + public bool ContinueOnError { get; } + + public IReadOnlyList Children { get; } + + public static IReadOnlyDictionary EmptyParameters { get; } = + new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + public static IReadOnlyList EmptyChildren { get; } = + Array.Empty(); +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs index c9e6b3c2..4852686f 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs @@ -1,77 +1,77 @@ -using System.Collections.ObjectModel; -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunExecutionGraphBuilder -{ - public PackRunExecutionGraph Build(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var steps = plan.Steps.Select(ConvertStep).ToList(); - var failurePolicy = plan.FailurePolicy; - return new PackRunExecutionGraph(steps, failurePolicy); - } - - private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step) - { - var kind = DetermineKind(step.Type); - var parameters = step.Parameters is null - ? PackRunExecutionStep.EmptyParameters - : new ReadOnlyDictionary( - new Dictionary(step.Parameters, StringComparer.Ordinal)); - - var children = step.Children is null - ? PackRunExecutionStep.EmptyChildren - : step.Children.Select(ConvertStep).ToList(); - - var maxParallel = TryGetInt(parameters, "maxParallel"); - var continueOnError = TryGetBool(parameters, "continueOnError"); - - return new PackRunExecutionStep( - step.Id, - step.TemplateId, - kind, - step.Enabled, - step.Uses, - parameters, - step.ApprovalId, - step.GateMessage, - maxParallel, - continueOnError, - children); - } - - private static PackRunStepKind DetermineKind(string? type) - => type switch - { - "run" => PackRunStepKind.Run, - "gate.approval" => PackRunStepKind.GateApproval, - "gate.policy" => PackRunStepKind.GatePolicy, - "parallel" => PackRunStepKind.Parallel, - "map" => PackRunStepKind.Map, - _ => PackRunStepKind.Unknown - }; - - private static int? TryGetInt(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) - { - return null; - } - - return jsonValue.TryGetValue(out var result) ? result : null; - } - - private static bool TryGetBool(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) - { - return false; - } - - return jsonValue.TryGetValue(out var result) && result; - } -} +using System.Collections.ObjectModel; +using System.Text.Json.Nodes; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution; + +public sealed class PackRunExecutionGraphBuilder +{ + public PackRunExecutionGraph Build(TaskPackPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + var steps = plan.Steps.Select(ConvertStep).ToList(); + var failurePolicy = plan.FailurePolicy; + return new PackRunExecutionGraph(steps, failurePolicy); + } + + private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step) + { + var kind = DetermineKind(step.Type); + var parameters = step.Parameters is null + ? PackRunExecutionStep.EmptyParameters + : new ReadOnlyDictionary( + new Dictionary(step.Parameters, StringComparer.Ordinal)); + + var children = step.Children is null + ? PackRunExecutionStep.EmptyChildren + : step.Children.Select(ConvertStep).ToList(); + + var maxParallel = TryGetInt(parameters, "maxParallel"); + var continueOnError = TryGetBool(parameters, "continueOnError"); + + return new PackRunExecutionStep( + step.Id, + step.TemplateId, + kind, + step.Enabled, + step.Uses, + parameters, + step.ApprovalId, + step.GateMessage, + maxParallel, + continueOnError, + children); + } + + private static PackRunStepKind DetermineKind(string? type) + => type switch + { + "run" => PackRunStepKind.Run, + "gate.approval" => PackRunStepKind.GateApproval, + "gate.policy" => PackRunStepKind.GatePolicy, + "parallel" => PackRunStepKind.Parallel, + "map" => PackRunStepKind.Map, + _ => PackRunStepKind.Unknown + }; + + private static int? TryGetInt(IReadOnlyDictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) + { + return null; + } + + return jsonValue.TryGetValue(out var result) ? result : null; + } + + private static bool TryGetBool(IReadOnlyDictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) + { + return false; + } + + return jsonValue.TryGetValue(out var result) && result; + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs index a0873ab0..c4bc27c3 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs @@ -1,159 +1,159 @@ -using System.Collections.ObjectModel; -using System.Linq; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class PackRunGateStateUpdater -{ - public static PackRunGateStateUpdateResult Apply( - PackRunState state, - PackRunExecutionGraph graph, - PackRunApprovalCoordinator coordinator, - DateTimeOffset timestamp) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(graph); - ArgumentNullException.ThrowIfNull(coordinator); - - var approvals = coordinator.GetApprovals() - .SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval))) - .GroupBy(tuple => tuple.stepId, StringComparer.Ordinal) - .ToDictionary( - group => group.Key, - group => group.First().approval, - StringComparer.Ordinal); - - var mutable = new Dictionary(state.Steps, StringComparer.Ordinal); - var changed = false; - var hasBlockingFailure = false; - - foreach (var step in EnumerateSteps(graph.Steps)) - { - if (!mutable.TryGetValue(step.Id, out var record)) - { - continue; - } - - switch (step.Kind) - { - case PackRunStepKind.GateApproval: - if (!approvals.TryGetValue(step.Id, out var approvalState)) - { - continue; - } - - switch (approvalState.Status) - { - case PackRunApprovalStatus.Pending: - break; - - case PackRunApprovalStatus.Approved: - if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - break; - - case PackRunApprovalStatus.Rejected: - case PackRunApprovalStatus.Expired: - var failureReason = BuildFailureReason(approvalState); - if (record.Status != PackRunStepExecutionStatus.Failed || - !string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal)) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Failed, - StatusReason = failureReason, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - hasBlockingFailure = true; - break; - } - - break; - - case PackRunStepKind.GatePolicy: - if (record.Status == PackRunStepExecutionStatus.Pending && - string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal)) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - break; - } - } - - if (!changed) - { - return new PackRunGateStateUpdateResult(state, hasBlockingFailure); - } - - var updatedState = state with - { - UpdatedAt = timestamp, - Steps = new ReadOnlyDictionary(mutable) - }; - - return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure); - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - if (steps.Count == 0) - { - yield break; - } - - foreach (var step in steps) - { - yield return step; - - if (step.Children.Count > 0) - { - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } - } - - private static string BuildFailureReason(PackRunApprovalState state) - { - var baseReason = state.Status switch - { - PackRunApprovalStatus.Rejected => "approval-rejected", - PackRunApprovalStatus.Expired => "approval-expired", - _ => "approval-invalid" - }; - - if (string.IsNullOrWhiteSpace(state.Summary)) - { - return baseReason; - } - - var summary = state.Summary.Trim(); - return $"{baseReason}:{summary}"; - } -} - -public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure); +using System.Collections.ObjectModel; +using System.Linq; + +namespace StellaOps.TaskRunner.Core.Execution; + +public static class PackRunGateStateUpdater +{ + public static PackRunGateStateUpdateResult Apply( + PackRunState state, + PackRunExecutionGraph graph, + PackRunApprovalCoordinator coordinator, + DateTimeOffset timestamp) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(coordinator); + + var approvals = coordinator.GetApprovals() + .SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval))) + .GroupBy(tuple => tuple.stepId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.First().approval, + StringComparer.Ordinal); + + var mutable = new Dictionary(state.Steps, StringComparer.Ordinal); + var changed = false; + var hasBlockingFailure = false; + + foreach (var step in EnumerateSteps(graph.Steps)) + { + if (!mutable.TryGetValue(step.Id, out var record)) + { + continue; + } + + switch (step.Kind) + { + case PackRunStepKind.GateApproval: + if (!approvals.TryGetValue(step.Id, out var approvalState)) + { + continue; + } + + switch (approvalState.Status) + { + case PackRunApprovalStatus.Pending: + break; + + case PackRunApprovalStatus.Approved: + if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null) + { + mutable[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Succeeded, + StatusReason = null, + LastTransitionAt = timestamp, + NextAttemptAt = null + }; + changed = true; + } + + break; + + case PackRunApprovalStatus.Rejected: + case PackRunApprovalStatus.Expired: + var failureReason = BuildFailureReason(approvalState); + if (record.Status != PackRunStepExecutionStatus.Failed || + !string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal)) + { + mutable[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Failed, + StatusReason = failureReason, + LastTransitionAt = timestamp, + NextAttemptAt = null + }; + changed = true; + } + + hasBlockingFailure = true; + break; + } + + break; + + case PackRunStepKind.GatePolicy: + if (record.Status == PackRunStepExecutionStatus.Pending && + string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal)) + { + mutable[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Succeeded, + StatusReason = null, + LastTransitionAt = timestamp, + NextAttemptAt = null + }; + changed = true; + } + + break; + } + } + + if (!changed) + { + return new PackRunGateStateUpdateResult(state, hasBlockingFailure); + } + + var updatedState = state with + { + UpdatedAt = timestamp, + Steps = new ReadOnlyDictionary(mutable) + }; + + return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure); + } + + private static IEnumerable EnumerateSteps(IReadOnlyList steps) + { + if (steps.Count == 0) + { + yield break; + } + + foreach (var step in steps) + { + yield return step; + + if (step.Children.Count > 0) + { + foreach (var child in EnumerateSteps(step.Children)) + { + yield return child; + } + } + } + } + + private static string BuildFailureReason(PackRunApprovalState state) + { + var baseReason = state.Status switch + { + PackRunApprovalStatus.Rejected => "approval-rejected", + PackRunApprovalStatus.Expired => "approval-expired", + _ => "approval-invalid" + }; + + if (string.IsNullOrWhiteSpace(state.Summary)) + { + return baseReason; + } + + var summary = state.Summary.Trim(); + return $"{baseReason}:{summary}"; + } +} + +public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure); diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs index debec842..f4ffb45a 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs @@ -1,50 +1,50 @@ -using System.Collections.ObjectModel; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed record PackRunState( - string RunId, - string PlanHash, - TaskPackPlanFailurePolicy FailurePolicy, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyDictionary Steps) -{ - public static PackRunState Create( - string runId, - string planHash, - TaskPackPlanFailurePolicy failurePolicy, - IReadOnlyDictionary steps, - DateTimeOffset timestamp) - => new( - runId, - planHash, - failurePolicy, - timestamp, - timestamp, - new ReadOnlyDictionary(new Dictionary(steps, StringComparer.Ordinal))); -} - -public sealed record PackRunStepStateRecord( - string StepId, - PackRunStepKind Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); - -public interface IPackRunStateStore -{ - Task GetAsync(string runId, CancellationToken cancellationToken); - - Task SaveAsync(PackRunState state, CancellationToken cancellationToken); - - Task> ListAsync(CancellationToken cancellationToken); -} +using System.Collections.ObjectModel; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution; + +public sealed record PackRunState( + string RunId, + string PlanHash, + TaskPackPlanFailurePolicy FailurePolicy, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + IReadOnlyDictionary Steps) +{ + public static PackRunState Create( + string runId, + string planHash, + TaskPackPlanFailurePolicy failurePolicy, + IReadOnlyDictionary steps, + DateTimeOffset timestamp) + => new( + runId, + planHash, + failurePolicy, + timestamp, + timestamp, + new ReadOnlyDictionary(new Dictionary(steps, StringComparer.Ordinal))); +} + +public sealed record PackRunStepStateRecord( + string StepId, + PackRunStepKind Kind, + bool Enabled, + bool ContinueOnError, + int? MaxParallel, + string? ApprovalId, + string? GateMessage, + PackRunStepExecutionStatus Status, + int Attempts, + DateTimeOffset? LastTransitionAt, + DateTimeOffset? NextAttemptAt, + string? StatusReason); + +public interface IPackRunStateStore +{ + Task GetAsync(string runId, CancellationToken cancellationToken); + + Task SaveAsync(PackRunState state, CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs index 50fc4160..73920e82 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs @@ -1,121 +1,121 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class PackRunStepStateMachine -{ - public static PackRunStepState Create(DateTimeOffset? createdAt = null) - => new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null); - - public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Pending) - { - throw new InvalidOperationException($"Cannot start step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Running, - LastTransitionAt = startedAt, - NextAttemptAt = null - }; - } - - public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Running) - { - throw new InvalidOperationException($"Cannot complete step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Succeeded, - Attempts = state.Attempts + 1, - LastTransitionAt = completedAt, - NextAttemptAt = null - }; - } - - public static PackRunStepFailureResult RegisterFailure( - PackRunStepState state, - DateTimeOffset failedAt, - TaskPackPlanFailurePolicy failurePolicy) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(failurePolicy); - - if (state.Status is not PackRunStepExecutionStatus.Running) - { - throw new InvalidOperationException($"Cannot register failure from status {state.Status}."); - } - - var attempts = state.Attempts + 1; - if (attempts < failurePolicy.MaxAttempts) - { - var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds)); - var nextAttemptAt = failedAt + backoff; - var nextState = state with - { - Status = PackRunStepExecutionStatus.Pending, - Attempts = attempts, - LastTransitionAt = failedAt, - NextAttemptAt = nextAttemptAt - }; - - return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry); - } - - var finalState = state with - { - Status = PackRunStepExecutionStatus.Failed, - Attempts = attempts, - LastTransitionAt = failedAt, - NextAttemptAt = null - }; - - return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort); - } - - public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Pending) - { - throw new InvalidOperationException($"Cannot skip step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Skipped, - LastTransitionAt = skippedAt, - NextAttemptAt = null - }; - } -} - -public sealed record PackRunStepState( - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt); - -public enum PackRunStepExecutionStatus -{ - Pending = 0, - Running, - Succeeded, - Failed, - Skipped -} - -public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome); - -public enum PackRunStepFailureOutcome -{ - Retry = 0, - Abort -} +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution; + +public static class PackRunStepStateMachine +{ + public static PackRunStepState Create(DateTimeOffset? createdAt = null) + => new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null); + + public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt) + { + ArgumentNullException.ThrowIfNull(state); + if (state.Status is not PackRunStepExecutionStatus.Pending) + { + throw new InvalidOperationException($"Cannot start step from status {state.Status}."); + } + + return state with + { + Status = PackRunStepExecutionStatus.Running, + LastTransitionAt = startedAt, + NextAttemptAt = null + }; + } + + public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt) + { + ArgumentNullException.ThrowIfNull(state); + if (state.Status is not PackRunStepExecutionStatus.Running) + { + throw new InvalidOperationException($"Cannot complete step from status {state.Status}."); + } + + return state with + { + Status = PackRunStepExecutionStatus.Succeeded, + Attempts = state.Attempts + 1, + LastTransitionAt = completedAt, + NextAttemptAt = null + }; + } + + public static PackRunStepFailureResult RegisterFailure( + PackRunStepState state, + DateTimeOffset failedAt, + TaskPackPlanFailurePolicy failurePolicy) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(failurePolicy); + + if (state.Status is not PackRunStepExecutionStatus.Running) + { + throw new InvalidOperationException($"Cannot register failure from status {state.Status}."); + } + + var attempts = state.Attempts + 1; + if (attempts < failurePolicy.MaxAttempts) + { + var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds)); + var nextAttemptAt = failedAt + backoff; + var nextState = state with + { + Status = PackRunStepExecutionStatus.Pending, + Attempts = attempts, + LastTransitionAt = failedAt, + NextAttemptAt = nextAttemptAt + }; + + return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry); + } + + var finalState = state with + { + Status = PackRunStepExecutionStatus.Failed, + Attempts = attempts, + LastTransitionAt = failedAt, + NextAttemptAt = null + }; + + return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort); + } + + public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt) + { + ArgumentNullException.ThrowIfNull(state); + if (state.Status is not PackRunStepExecutionStatus.Pending) + { + throw new InvalidOperationException($"Cannot skip step from status {state.Status}."); + } + + return state with + { + Status = PackRunStepExecutionStatus.Skipped, + LastTransitionAt = skippedAt, + NextAttemptAt = null + }; + } +} + +public sealed record PackRunStepState( + PackRunStepExecutionStatus Status, + int Attempts, + DateTimeOffset? LastTransitionAt, + DateTimeOffset? NextAttemptAt); + +public enum PackRunStepExecutionStatus +{ + Pending = 0, + Running, + Succeeded, + Failed, + Skipped +} + +public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome); + +public enum PackRunStepFailureOutcome +{ + Retry = 0, + Abort +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs index 707959fd..7185b55c 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs @@ -1,78 +1,78 @@ -using System.Collections.ObjectModel; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution.Simulation; - -public sealed class PackRunSimulationEngine -{ - private readonly PackRunExecutionGraphBuilder graphBuilder; - - public PackRunSimulationEngine() - { - graphBuilder = new PackRunExecutionGraphBuilder(); - } - - public PackRunSimulationResult Simulate(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var graph = graphBuilder.Build(plan); - var steps = graph.Steps.Select(ConvertStep).ToList(); - var outputs = BuildOutputs(plan.Outputs); - - return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy); - } - - private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step) - { - var status = DetermineStatus(step); - var children = step.Children.Count == 0 - ? PackRunSimulationNode.Empty - : new ReadOnlyCollection(step.Children.Select(ConvertStep).ToList()); - - return new PackRunSimulationNode( - step.Id, - step.TemplateId, - step.Kind, - step.Enabled, - step.Uses, - step.ApprovalId, - step.GateMessage, - step.Parameters, - step.MaxParallel, - step.ContinueOnError, - status, - children); - } - - private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step) - { - if (!step.Enabled) - { - return PackRunSimulationStatus.Skipped; - } - - return step.Kind switch - { - PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval, - PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy, - _ => PackRunSimulationStatus.Pending - }; - } - - private static IReadOnlyList BuildOutputs(IReadOnlyList outputs) - { - if (outputs.Count == 0) - { - return PackRunSimulationOutput.Empty; - } - - var list = new List(outputs.Count); - foreach (var output in outputs) - { - list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression)); - } - - return new ReadOnlyCollection(list); - } -} +using System.Collections.ObjectModel; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution.Simulation; + +public sealed class PackRunSimulationEngine +{ + private readonly PackRunExecutionGraphBuilder graphBuilder; + + public PackRunSimulationEngine() + { + graphBuilder = new PackRunExecutionGraphBuilder(); + } + + public PackRunSimulationResult Simulate(TaskPackPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + var graph = graphBuilder.Build(plan); + var steps = graph.Steps.Select(ConvertStep).ToList(); + var outputs = BuildOutputs(plan.Outputs); + + return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy); + } + + private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step) + { + var status = DetermineStatus(step); + var children = step.Children.Count == 0 + ? PackRunSimulationNode.Empty + : new ReadOnlyCollection(step.Children.Select(ConvertStep).ToList()); + + return new PackRunSimulationNode( + step.Id, + step.TemplateId, + step.Kind, + step.Enabled, + step.Uses, + step.ApprovalId, + step.GateMessage, + step.Parameters, + step.MaxParallel, + step.ContinueOnError, + status, + children); + } + + private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step) + { + if (!step.Enabled) + { + return PackRunSimulationStatus.Skipped; + } + + return step.Kind switch + { + PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval, + PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy, + _ => PackRunSimulationStatus.Pending + }; + } + + private static IReadOnlyList BuildOutputs(IReadOnlyList outputs) + { + if (outputs.Count == 0) + { + return PackRunSimulationOutput.Empty; + } + + var list = new List(outputs.Count); + foreach (var output in outputs) + { + list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression)); + } + + return new ReadOnlyCollection(list); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs index 7f220fa3..7bac9c1c 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs @@ -1,131 +1,131 @@ -using System.Collections.ObjectModel; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution.Simulation; - -public sealed class PackRunSimulationResult -{ - public PackRunSimulationResult( - IReadOnlyList steps, - IReadOnlyList outputs, - TaskPackPlanFailurePolicy failurePolicy) - { - Steps = steps ?? throw new ArgumentNullException(nameof(steps)); - Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs)); - FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy)); - } - - public IReadOnlyList Steps { get; } - - public IReadOnlyList Outputs { get; } - - public TaskPackPlanFailurePolicy FailurePolicy { get; } - - public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement); - - private static bool ContainsApprovalRequirement(PackRunSimulationNode node) - { - if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy) - { - return true; - } - - return node.Children.Any(ContainsApprovalRequirement); - } -} - -public sealed class PackRunSimulationNode -{ - public PackRunSimulationNode( - string id, - string templateId, - PackRunStepKind kind, - bool enabled, - string? uses, - string? approvalId, - string? gateMessage, - IReadOnlyDictionary parameters, - int? maxParallel, - bool continueOnError, - PackRunSimulationStatus status, - IReadOnlyList children) - { - Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; - TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; - Kind = kind; - Enabled = enabled; - Uses = uses; - ApprovalId = approvalId; - GateMessage = gateMessage; - Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); - MaxParallel = maxParallel; - ContinueOnError = continueOnError; - Status = status; - Children = children ?? throw new ArgumentNullException(nameof(children)); - } - - public string Id { get; } - - public string TemplateId { get; } - - public PackRunStepKind Kind { get; } - - public bool Enabled { get; } - - public string? Uses { get; } - - public string? ApprovalId { get; } - - public string? GateMessage { get; } - - public IReadOnlyDictionary Parameters { get; } - - public int? MaxParallel { get; } - - public bool ContinueOnError { get; } - - public PackRunSimulationStatus Status { get; } - - public IReadOnlyList Children { get; } - - public static IReadOnlyList Empty { get; } = - new ReadOnlyCollection(Array.Empty()); -} - -public enum PackRunSimulationStatus -{ - Pending = 0, - Skipped, - RequiresApproval, - RequiresPolicy -} - -public sealed class PackRunSimulationOutput -{ - public PackRunSimulationOutput( - string name, - string type, - TaskPackPlanParameterValue? path, - TaskPackPlanParameterValue? expression) - { - Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name; - Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type; - Path = path; - Expression = expression; - } - - public string Name { get; } - - public string Type { get; } - - public TaskPackPlanParameterValue? Path { get; } - - public TaskPackPlanParameterValue? Expression { get; } - - public bool RequiresRuntimeValue => - (Path?.RequiresRuntimeValue ?? false) || - (Expression?.RequiresRuntimeValue ?? false); - - public static IReadOnlyList Empty { get; } = - new ReadOnlyCollection(Array.Empty()); -} +using System.Collections.ObjectModel; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Core.Execution.Simulation; + +public sealed class PackRunSimulationResult +{ + public PackRunSimulationResult( + IReadOnlyList steps, + IReadOnlyList outputs, + TaskPackPlanFailurePolicy failurePolicy) + { + Steps = steps ?? throw new ArgumentNullException(nameof(steps)); + Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs)); + FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy)); + } + + public IReadOnlyList Steps { get; } + + public IReadOnlyList Outputs { get; } + + public TaskPackPlanFailurePolicy FailurePolicy { get; } + + public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement); + + private static bool ContainsApprovalRequirement(PackRunSimulationNode node) + { + if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy) + { + return true; + } + + return node.Children.Any(ContainsApprovalRequirement); + } +} + +public sealed class PackRunSimulationNode +{ + public PackRunSimulationNode( + string id, + string templateId, + PackRunStepKind kind, + bool enabled, + string? uses, + string? approvalId, + string? gateMessage, + IReadOnlyDictionary parameters, + int? maxParallel, + bool continueOnError, + PackRunSimulationStatus status, + IReadOnlyList children) + { + Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; + TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; + Kind = kind; + Enabled = enabled; + Uses = uses; + ApprovalId = approvalId; + GateMessage = gateMessage; + Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); + MaxParallel = maxParallel; + ContinueOnError = continueOnError; + Status = status; + Children = children ?? throw new ArgumentNullException(nameof(children)); + } + + public string Id { get; } + + public string TemplateId { get; } + + public PackRunStepKind Kind { get; } + + public bool Enabled { get; } + + public string? Uses { get; } + + public string? ApprovalId { get; } + + public string? GateMessage { get; } + + public IReadOnlyDictionary Parameters { get; } + + public int? MaxParallel { get; } + + public bool ContinueOnError { get; } + + public PackRunSimulationStatus Status { get; } + + public IReadOnlyList Children { get; } + + public static IReadOnlyList Empty { get; } = + new ReadOnlyCollection(Array.Empty()); +} + +public enum PackRunSimulationStatus +{ + Pending = 0, + Skipped, + RequiresApproval, + RequiresPolicy +} + +public sealed class PackRunSimulationOutput +{ + public PackRunSimulationOutput( + string name, + string type, + TaskPackPlanParameterValue? path, + TaskPackPlanParameterValue? expression) + { + Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name; + Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type; + Path = path; + Expression = expression; + } + + public string Name { get; } + + public string Type { get; } + + public TaskPackPlanParameterValue? Path { get; } + + public TaskPackPlanParameterValue? Expression { get; } + + public bool RequiresRuntimeValue => + (Path?.RequiresRuntimeValue ?? false) || + (Expression?.RequiresRuntimeValue ?? false); + + public static IReadOnlyList Empty { get; } = + new ReadOnlyCollection(Array.Empty()); +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs index 2f8da326..1e3ad19c 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs @@ -1,191 +1,191 @@ -using System.Text.Json; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// File-system backed implementation of intended for development and air-gapped smoke tests. -/// -public sealed class FilePackRunStateStore : IPackRunStateStore -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - private readonly string rootPath; - private readonly SemaphoreSlim mutex = new(1, 1); - - public FilePackRunStateStore(string rootPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - - this.rootPath = Path.GetFullPath(rootPath); - Directory.CreateDirectory(this.rootPath); - } - - public async Task GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var path = GetPath(runId); - if (!File.Exists(path)) - { - return null; - } - - await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - return document?.ToDomain(); - } - - public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(state); - - var path = GetPath(state.RunId); - var document = StateDocument.FromDomain(state); - - Directory.CreateDirectory(rootPath); - - await mutex.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); - await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - } - finally - { - mutex.Release(); - } - } - - public async Task> ListAsync(CancellationToken cancellationToken) - { - if (!Directory.Exists(rootPath)) - { - return Array.Empty(); - } - - var states = new List(); - - var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly) - .OrderBy(file => file, StringComparer.Ordinal); - - foreach (var file in files) - { - cancellationToken.ThrowIfCancellationRequested(); - - await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - if (document is not null) - { - states.Add(document.ToDomain()); - } - } - - return states; - } - - private string GetPath(string runId) - { - var safeName = SanitizeFileName(runId); - return Path.Combine(rootPath, $"{safeName}.json"); - } - - private static string SanitizeFileName(string value) - { - var result = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - return result; - } - - private sealed record StateDocument( - string RunId, - string PlanHash, - TaskPackPlanFailurePolicy FailurePolicy, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyList Steps) - { - public static StateDocument FromDomain(PackRunState state) - { - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new StepDocument( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason)) - .ToList(); - - return new StateDocument( - state.RunId, - state.PlanHash, - state.FailurePolicy, - state.CreatedAt, - state.UpdatedAt, - steps); - } - - public PackRunState ToDomain() - { - var steps = Steps.ToDictionary( - step => step.StepId, - step => new PackRunStepStateRecord( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason), - StringComparer.Ordinal); - - return new PackRunState( - RunId, - PlanHash, - FailurePolicy, - CreatedAt, - UpdatedAt, - steps); - } - } - - private sealed record StepDocument( - string StepId, - PackRunStepKind Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); -} +using System.Text.Json; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Infrastructure.Execution; + +/// +/// File-system backed implementation of intended for development and air-gapped smoke tests. +/// +public sealed class FilePackRunStateStore : IPackRunStateStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly string rootPath; + private readonly SemaphoreSlim mutex = new(1, 1); + + public FilePackRunStateStore(string rootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); + + this.rootPath = Path.GetFullPath(rootPath); + Directory.CreateDirectory(this.rootPath); + } + + public async Task GetAsync(string runId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + + var path = GetPath(runId); + if (!File.Exists(path)) + { + return null; + } + + await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) + .ConfigureAwait(false); + + return document?.ToDomain(); + } + + public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(state); + + var path = GetPath(state.RunId); + var document = StateDocument.FromDomain(state); + + Directory.CreateDirectory(rootPath); + + await mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); + await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken) + .ConfigureAwait(false); + } + finally + { + mutex.Release(); + } + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(rootPath)) + { + return Array.Empty(); + } + + var states = new List(); + + var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly) + .OrderBy(file => file, StringComparer.Ordinal); + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read); + var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) + .ConfigureAwait(false); + + if (document is not null) + { + states.Add(document.ToDomain()); + } + } + + return states; + } + + private string GetPath(string runId) + { + var safeName = SanitizeFileName(runId); + return Path.Combine(rootPath, $"{safeName}.json"); + } + + private static string SanitizeFileName(string value) + { + var result = value.Trim(); + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + result = result.Replace(invalid, '_'); + } + + return result; + } + + private sealed record StateDocument( + string RunId, + string PlanHash, + TaskPackPlanFailurePolicy FailurePolicy, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + IReadOnlyList Steps) + { + public static StateDocument FromDomain(PackRunState state) + { + var steps = state.Steps.Values + .OrderBy(step => step.StepId, StringComparer.Ordinal) + .Select(step => new StepDocument( + step.StepId, + step.Kind, + step.Enabled, + step.ContinueOnError, + step.MaxParallel, + step.ApprovalId, + step.GateMessage, + step.Status, + step.Attempts, + step.LastTransitionAt, + step.NextAttemptAt, + step.StatusReason)) + .ToList(); + + return new StateDocument( + state.RunId, + state.PlanHash, + state.FailurePolicy, + state.CreatedAt, + state.UpdatedAt, + steps); + } + + public PackRunState ToDomain() + { + var steps = Steps.ToDictionary( + step => step.StepId, + step => new PackRunStepStateRecord( + step.StepId, + step.Kind, + step.Enabled, + step.ContinueOnError, + step.MaxParallel, + step.ApprovalId, + step.GateMessage, + step.Status, + step.Attempts, + step.LastTransitionAt, + step.NextAttemptAt, + step.StatusReason), + StringComparer.Ordinal); + + return new PackRunState( + RunId, + PlanHash, + FailurePolicy, + CreatedAt, + UpdatedAt, + steps); + } + } + + private sealed record StepDocument( + string StepId, + PackRunStepKind Kind, + bool Enabled, + bool ContinueOnError, + int? MaxParallel, + string? ApprovalId, + string? GateMessage, + PackRunStepExecutionStatus Status, + int Attempts, + DateTimeOffset? LastTransitionAt, + DateTimeOffset? NextAttemptAt, + string? StatusReason); +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs index 2f437c0a..1f3cb588 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs @@ -1,24 +1,24 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor -{ - public Task ExecuteAsync( - PackRunExecutionStep step, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken) - { - if (parameters.TryGetValue("simulateFailure", out var value) && - value.Value is JsonValue jsonValue && - jsonValue.TryGetValue(out var failure) && - failure) - { - return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested.")); - } - - return Task.FromResult(new PackRunStepExecutionResult(true)); - } -} +using System.Text.Json.Nodes; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Infrastructure.Execution; + +public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor +{ + public Task ExecuteAsync( + PackRunExecutionStep step, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + if (parameters.TryGetValue("simulateFailure", out var value) && + value.Value is JsonValue jsonValue && + jsonValue.TryGetValue(out var failure) && + failure) + { + return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested.")); + } + + return Task.FromResult(new PackRunStepExecutionResult(true)); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs index cc2ab909..6f6d14d4 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs @@ -1,105 +1,105 @@ -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; - -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilePackRunStateStoreTests -{ - [Fact] - public async Task SaveAndGetAsync_RoundTripsState() - { - var directory = CreateTempDirectory(); - try - { - var store = new FilePackRunStateStore(directory); - var original = CreateState("run:primary"); - - await store.SaveAsync(original, CancellationToken.None); - - var reloaded = await store.GetAsync("run:primary", CancellationToken.None); - Assert.NotNull(reloaded); - Assert.Equal(original.RunId, reloaded!.RunId); - Assert.Equal(original.PlanHash, reloaded.PlanHash); - Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy); - Assert.Equal(original.Steps.Count, reloaded.Steps.Count); - var step = Assert.Single(reloaded.Steps); - Assert.Equal("step-a", step.Key); - Assert.Equal(original.Steps["step-a"], step.Value); - } - finally - { - TryDelete(directory); - } - } - - [Fact] - public async Task ListAsync_ReturnsStatesInDeterministicOrder() - { - var directory = CreateTempDirectory(); - try - { - var store = new FilePackRunStateStore(directory); - var stateB = CreateState("run-b"); - var stateA = CreateState("run-a"); - - await store.SaveAsync(stateB, CancellationToken.None); - await store.SaveAsync(stateA, CancellationToken.None); - - var states = await store.ListAsync(CancellationToken.None); - - Assert.Collection(states, - first => Assert.Equal("run-a", first.RunId), - second => Assert.Equal("run-b", second.RunId)); - } - finally - { - TryDelete(directory); - } - } - - private static PackRunState CreateState(string runId) - { - var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false); - var steps = new Dictionary(StringComparer.Ordinal) - { - ["step-a"] = new PackRunStepStateRecord( - StepId: "step-a", - Kind: PackRunStepKind.Run, - Enabled: true, - ContinueOnError: false, - MaxParallel: null, - ApprovalId: null, - GateMessage: null, - Status: PackRunStepExecutionStatus.Pending, - Attempts: 1, - LastTransitionAt: DateTimeOffset.UtcNow, - NextAttemptAt: null, - StatusReason: null) - }; - - return PackRunState.Create(runId, "hash-123", failurePolicy, steps, DateTimeOffset.UtcNow); - } - - private static string CreateTempDirectory() - { - var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(path); - return path; - } - - private static void TryDelete(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch - { - // Swallow cleanup errors to avoid masking test assertions. - } - } -} +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; +using StellaOps.TaskRunner.Infrastructure.Execution; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class FilePackRunStateStoreTests +{ + [Fact] + public async Task SaveAndGetAsync_RoundTripsState() + { + var directory = CreateTempDirectory(); + try + { + var store = new FilePackRunStateStore(directory); + var original = CreateState("run:primary"); + + await store.SaveAsync(original, CancellationToken.None); + + var reloaded = await store.GetAsync("run:primary", CancellationToken.None); + Assert.NotNull(reloaded); + Assert.Equal(original.RunId, reloaded!.RunId); + Assert.Equal(original.PlanHash, reloaded.PlanHash); + Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy); + Assert.Equal(original.Steps.Count, reloaded.Steps.Count); + var step = Assert.Single(reloaded.Steps); + Assert.Equal("step-a", step.Key); + Assert.Equal(original.Steps["step-a"], step.Value); + } + finally + { + TryDelete(directory); + } + } + + [Fact] + public async Task ListAsync_ReturnsStatesInDeterministicOrder() + { + var directory = CreateTempDirectory(); + try + { + var store = new FilePackRunStateStore(directory); + var stateB = CreateState("run-b"); + var stateA = CreateState("run-a"); + + await store.SaveAsync(stateB, CancellationToken.None); + await store.SaveAsync(stateA, CancellationToken.None); + + var states = await store.ListAsync(CancellationToken.None); + + Assert.Collection(states, + first => Assert.Equal("run-a", first.RunId), + second => Assert.Equal("run-b", second.RunId)); + } + finally + { + TryDelete(directory); + } + } + + private static PackRunState CreateState(string runId) + { + var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false); + var steps = new Dictionary(StringComparer.Ordinal) + { + ["step-a"] = new PackRunStepStateRecord( + StepId: "step-a", + Kind: PackRunStepKind.Run, + Enabled: true, + ContinueOnError: false, + MaxParallel: null, + ApprovalId: null, + GateMessage: null, + Status: PackRunStepExecutionStatus.Pending, + Attempts: 1, + LastTransitionAt: DateTimeOffset.UtcNow, + NextAttemptAt: null, + StatusReason: null) + }; + + return PackRunState.Create(runId, "hash-123", failurePolicy, steps, DateTimeOffset.UtcNow); + } + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch + { + // Swallow cleanup errors to avoid masking test assertions. + } + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs index 65f32be4..8bda136b 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs @@ -1,68 +1,68 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunExecutionGraphBuilderTests -{ - [Fact] - public void Build_GeneratesParallelMetadata() - { - var manifest = TestManifests.Load(TestManifests.Parallel); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - - var builder = new PackRunExecutionGraphBuilder(); - var graph = builder.Build(plan); - - Assert.Equal(2, graph.FailurePolicy.MaxAttempts); - Assert.Equal(10, graph.FailurePolicy.BackoffSeconds); - - var parallel = Assert.Single(graph.Steps); - Assert.Equal(PackRunStepKind.Parallel, parallel.Kind); - Assert.True(parallel.Enabled); - Assert.Equal(2, parallel.MaxParallel); - Assert.True(parallel.ContinueOnError); - Assert.Equal(2, parallel.Children.Count); - Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); - } - - [Fact] - public void Build_PreservesMapIterationsAndDisabledSteps() - { - var planner = new TaskPackPlanner(); - var builder = new PackRunExecutionGraphBuilder(); - - // Map iterations - var mapManifest = TestManifests.Load(TestManifests.Map); - var inputs = new Dictionary - { - ["targets"] = new JsonArray("alpha", "beta", "gamma") - }; - - var mapPlan = planner.Plan(mapManifest, inputs).Plan!; - var mapGraph = builder.Build(mapPlan); - - var mapStep = Assert.Single(mapGraph.Steps); - Assert.Equal(PackRunStepKind.Map, mapStep.Kind); - Assert.Equal(3, mapStep.Children.Count); - Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); - - // Disabled conditional step - var conditionalManifest = TestManifests.Load(TestManifests.Sample); - var conditionalInputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(true) - }; - - var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!; - var conditionalGraph = builder.Build(conditionalPlan); - - var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step"); - Assert.False(applyStep.Enabled); - } -} +using System.Text.Json.Nodes; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class PackRunExecutionGraphBuilderTests +{ + [Fact] + public void Build_GeneratesParallelMetadata() + { + var manifest = TestManifests.Load(TestManifests.Parallel); + var planner = new TaskPackPlanner(); + + var result = planner.Plan(manifest); + Assert.True(result.Success); + var plan = result.Plan!; + + var builder = new PackRunExecutionGraphBuilder(); + var graph = builder.Build(plan); + + Assert.Equal(2, graph.FailurePolicy.MaxAttempts); + Assert.Equal(10, graph.FailurePolicy.BackoffSeconds); + + var parallel = Assert.Single(graph.Steps); + Assert.Equal(PackRunStepKind.Parallel, parallel.Kind); + Assert.True(parallel.Enabled); + Assert.Equal(2, parallel.MaxParallel); + Assert.True(parallel.ContinueOnError); + Assert.Equal(2, parallel.Children.Count); + Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); + } + + [Fact] + public void Build_PreservesMapIterationsAndDisabledSteps() + { + var planner = new TaskPackPlanner(); + var builder = new PackRunExecutionGraphBuilder(); + + // Map iterations + var mapManifest = TestManifests.Load(TestManifests.Map); + var inputs = new Dictionary + { + ["targets"] = new JsonArray("alpha", "beta", "gamma") + }; + + var mapPlan = planner.Plan(mapManifest, inputs).Plan!; + var mapGraph = builder.Build(mapPlan); + + var mapStep = Assert.Single(mapGraph.Steps); + Assert.Equal(PackRunStepKind.Map, mapStep.Kind); + Assert.Equal(3, mapStep.Children.Count); + Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); + + // Disabled conditional step + var conditionalManifest = TestManifests.Load(TestManifests.Sample); + var conditionalInputs = new Dictionary + { + ["dryRun"] = JsonValue.Create(true) + }; + + var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!; + var conditionalGraph = builder.Build(conditionalPlan); + + var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step"); + Assert.False(applyStep.Enabled); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs index 57c8d118..4b8f4506 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs @@ -1,150 +1,150 @@ -using System; -using System.Collections.Generic; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunGateStateUpdaterTests -{ - private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch; - private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5); - - [Fact] - public void Apply_ApprovedGate_ClearsReasonAndSucceeds() - { - var plan = BuildApprovalPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - coordinator.Approve("security-review", "approver-1", UpdateTimestamp); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.False(result.HasBlockingFailure); - Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); - - var gate = result.State.Steps["approval"]; - Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); - Assert.Null(gate.StatusReason); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - } - - [Fact] - public void Apply_RejectedGate_FlagsFailure() - { - var plan = BuildApprovalPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe"); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.True(result.HasBlockingFailure); - Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); - - var gate = result.State.Steps["approval"]; - Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status); - Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - } - - [Fact] - public void Apply_PolicyGate_ClearsPendingReason() - { - var plan = BuildPolicyPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.False(result.HasBlockingFailure); - - var gate = result.State.Steps["policy-check"]; - Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); - Assert.Null(gate.StatusReason); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - - var prepare = result.State.Steps["prepare"]; - Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status); - Assert.Null(prepare.StatusReason); - } - - private static TaskPackPlan BuildApprovalPlan() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false) - }; - - return planner.Plan(manifest, inputs).Plan!; - } - - private static TaskPackPlan BuildPolicyPlan() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - return planner.Plan(manifest).Plan!; - } - - private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph) - { - var steps = new Dictionary(StringComparer.Ordinal); - - foreach (var step in EnumerateSteps(graph.Steps)) - { - var status = PackRunStepExecutionStatus.Pending; - string? reason = null; - - if (!step.Enabled) - { - status = PackRunStepExecutionStatus.Skipped; - reason = "disabled"; - } - else if (step.Kind == PackRunStepKind.GateApproval) - { - reason = "requires-approval"; - } - else if (step.Kind == PackRunStepKind.GatePolicy) - { - reason = "requires-policy"; - } - - steps[step.Id] = new PackRunStepStateRecord( - step.Id, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - status, - Attempts: 0, - LastTransitionAt: null, - NextAttemptAt: null, - StatusReason: reason); - } - - return PackRunState.Create("run-1", plan.Hash, graph.FailurePolicy, steps, RequestedAt); - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - foreach (var step in steps) - { - yield return step; - - if (step.Children.Count > 0) - { - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } - } -} +using System; +using System.Collections.Generic; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class PackRunGateStateUpdaterTests +{ + private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch; + private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5); + + [Fact] + public void Apply_ApprovedGate_ClearsReasonAndSucceeds() + { + var plan = BuildApprovalPlan(); + var graph = new PackRunExecutionGraphBuilder().Build(plan); + var state = CreateInitialState(plan, graph); + var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); + coordinator.Approve("security-review", "approver-1", UpdateTimestamp); + + var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); + + Assert.False(result.HasBlockingFailure); + Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); + + var gate = result.State.Steps["approval"]; + Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); + Assert.Null(gate.StatusReason); + Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); + } + + [Fact] + public void Apply_RejectedGate_FlagsFailure() + { + var plan = BuildApprovalPlan(); + var graph = new PackRunExecutionGraphBuilder().Build(plan); + var state = CreateInitialState(plan, graph); + var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); + coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe"); + + var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); + + Assert.True(result.HasBlockingFailure); + Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); + + var gate = result.State.Steps["approval"]; + Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status); + Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal); + Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); + } + + [Fact] + public void Apply_PolicyGate_ClearsPendingReason() + { + var plan = BuildPolicyPlan(); + var graph = new PackRunExecutionGraphBuilder().Build(plan); + var state = CreateInitialState(plan, graph); + var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); + + var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); + + Assert.False(result.HasBlockingFailure); + + var gate = result.State.Steps["policy-check"]; + Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); + Assert.Null(gate.StatusReason); + Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); + + var prepare = result.State.Steps["prepare"]; + Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status); + Assert.Null(prepare.StatusReason); + } + + private static TaskPackPlan BuildApprovalPlan() + { + var manifest = TestManifests.Load(TestManifests.Sample); + var planner = new TaskPackPlanner(); + var inputs = new Dictionary + { + ["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false) + }; + + return planner.Plan(manifest, inputs).Plan!; + } + + private static TaskPackPlan BuildPolicyPlan() + { + var manifest = TestManifests.Load(TestManifests.PolicyGate); + var planner = new TaskPackPlanner(); + return planner.Plan(manifest).Plan!; + } + + private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph) + { + var steps = new Dictionary(StringComparer.Ordinal); + + foreach (var step in EnumerateSteps(graph.Steps)) + { + var status = PackRunStepExecutionStatus.Pending; + string? reason = null; + + if (!step.Enabled) + { + status = PackRunStepExecutionStatus.Skipped; + reason = "disabled"; + } + else if (step.Kind == PackRunStepKind.GateApproval) + { + reason = "requires-approval"; + } + else if (step.Kind == PackRunStepKind.GatePolicy) + { + reason = "requires-policy"; + } + + steps[step.Id] = new PackRunStepStateRecord( + step.Id, + step.Kind, + step.Enabled, + step.ContinueOnError, + step.MaxParallel, + step.ApprovalId, + step.GateMessage, + status, + Attempts: 0, + LastTransitionAt: null, + NextAttemptAt: null, + StatusReason: reason); + } + + return PackRunState.Create("run-1", plan.Hash, graph.FailurePolicy, steps, RequestedAt); + } + + private static IEnumerable EnumerateSteps(IReadOnlyList steps) + { + foreach (var step in steps) + { + yield return step; + + if (step.Children.Count > 0) + { + foreach (var child in EnumerateSteps(step.Children)) + { + yield return child; + } + } + } + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs index 46d9f43a..78e14c0b 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs @@ -1,75 +1,75 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunSimulationEngineTests -{ - [Fact] - public void Simulate_IdentifiesGateStatuses() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy); - Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status); - - var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run); - Assert.Equal(PackRunSimulationStatus.Pending, run.Status); - } - - [Fact] - public void Simulate_MarksDisabledStepsAndOutputs() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(true) - }; - - var plan = planner.Plan(manifest, inputs).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var applyStep = result.Steps.Single(step => step.Id == "apply-step"); - Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status); - - Assert.Empty(result.Outputs); - Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts); - Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds); - } - - [Fact] - public void Simulate_ProjectsOutputsAndRuntimeFlags() - { - var manifest = TestManifests.Load(TestManifests.Output); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var step = Assert.Single(result.Steps); - Assert.Equal(PackRunStepKind.Run, step.Kind); - - Assert.Collection(result.Outputs, - bundle => - { - Assert.Equal("bundlePath", bundle.Name); - Assert.False(bundle.RequiresRuntimeValue); - }, - evidence => - { - Assert.Equal("evidenceModel", evidence.Name); - Assert.True(evidence.RequiresRuntimeValue); - }); - } -} +using System.Text.Json.Nodes; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Execution.Simulation; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class PackRunSimulationEngineTests +{ + [Fact] + public void Simulate_IdentifiesGateStatuses() + { + var manifest = TestManifests.Load(TestManifests.PolicyGate); + var planner = new TaskPackPlanner(); + var plan = planner.Plan(manifest).Plan!; + + var engine = new PackRunSimulationEngine(); + var result = engine.Simulate(plan); + + var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy); + Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status); + + var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run); + Assert.Equal(PackRunSimulationStatus.Pending, run.Status); + } + + [Fact] + public void Simulate_MarksDisabledStepsAndOutputs() + { + var manifest = TestManifests.Load(TestManifests.Sample); + var planner = new TaskPackPlanner(); + var inputs = new Dictionary + { + ["dryRun"] = JsonValue.Create(true) + }; + + var plan = planner.Plan(manifest, inputs).Plan!; + + var engine = new PackRunSimulationEngine(); + var result = engine.Simulate(plan); + + var applyStep = result.Steps.Single(step => step.Id == "apply-step"); + Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status); + + Assert.Empty(result.Outputs); + Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts); + Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds); + } + + [Fact] + public void Simulate_ProjectsOutputsAndRuntimeFlags() + { + var manifest = TestManifests.Load(TestManifests.Output); + var planner = new TaskPackPlanner(); + var plan = planner.Plan(manifest).Plan!; + + var engine = new PackRunSimulationEngine(); + var result = engine.Simulate(plan); + + var step = Assert.Single(result.Steps); + Assert.Equal(PackRunStepKind.Run, step.Kind); + + Assert.Collection(result.Outputs, + bundle => + { + Assert.Equal("bundlePath", bundle.Name); + Assert.False(bundle.RequiresRuntimeValue); + }, + evidence => + { + Assert.Equal("evidenceModel", evidence.Name); + Assert.True(evidence.RequiresRuntimeValue); + }); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs index 017779cb..87799923 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs @@ -1,66 +1,66 @@ -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunStepStateMachineTests -{ - private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false); - - [Fact] - public void Start_FromPending_SetsRunning() - { - var state = PackRunStepStateMachine.Create(); - var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - - Assert.Equal(PackRunStepExecutionStatus.Running, started.Status); - Assert.Equal(0, started.Attempts); - } - - [Fact] - public void CompleteSuccess_IncrementsAttempts() - { - var state = PackRunStepStateMachine.Create(); - var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1)); - - Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status); - Assert.Equal(1, completed.Attempts); - Assert.Null(completed.NextAttemptAt); - } - - [Fact] - public void RegisterFailure_SchedulesRetryUntilMaxAttempts() - { - var state = PackRunStepStateMachine.Create(); - var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - - var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome); - Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status); - Assert.Equal(1, firstFailure.State.Attempts); - Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt); - - var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7)); - var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome); - Assert.Equal(2, secondFailure.State.Attempts); - - var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds)); - var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome); - Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status); - Assert.Equal(3, terminalFailure.State.Attempts); - Assert.Null(terminalFailure.State.NextAttemptAt); - } - - [Fact] - public void Skip_FromPending_SetsSkipped() - { - var state = PackRunStepStateMachine.Create(); - var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1)); - - Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status); - Assert.Equal(0, skipped.Attempts); - } -} +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Tests; + +public sealed class PackRunStepStateMachineTests +{ + private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false); + + [Fact] + public void Start_FromPending_SetsRunning() + { + var state = PackRunStepStateMachine.Create(); + var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); + + Assert.Equal(PackRunStepExecutionStatus.Running, started.Status); + Assert.Equal(0, started.Attempts); + } + + [Fact] + public void CompleteSuccess_IncrementsAttempts() + { + var state = PackRunStepStateMachine.Create(); + var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); + var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1)); + + Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status); + Assert.Equal(1, completed.Attempts); + Assert.Null(completed.NextAttemptAt); + } + + [Fact] + public void RegisterFailure_SchedulesRetryUntilMaxAttempts() + { + var state = PackRunStepStateMachine.Create(); + var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); + + var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy); + Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome); + Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status); + Assert.Equal(1, firstFailure.State.Attempts); + Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt); + + var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7)); + var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy); + Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome); + Assert.Equal(2, secondFailure.State.Attempts); + + var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds)); + var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy); + Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome); + Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status); + Assert.Equal(3, terminalFailure.State.Attempts); + Assert.Null(terminalFailure.State.NextAttemptAt); + } + + [Fact] + public void Skip_FromPending_SetsSkipped() + { + var state = PackRunStepStateMachine.Create(); + var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1)); + + Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status); + Assert.Equal(0, skipped.Attempts); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs index c74b37de..4a67373c 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs @@ -1,242 +1,242 @@ -using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.TaskPacks; -using StellaOps.TaskRunner.Infrastructure.Execution; -using StellaOps.TaskRunner.WebService; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.Configure(builder.Configuration.GetSection("TaskRunner")); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - return new FilePackRunStateStore(options.RunStatePath); -}); -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.MapPost("/v1/task-runner/simulations", async ( - [FromBody] SimulationRequest request, - TaskPackManifestLoader loader, - TaskPackPlanner planner, - PackRunSimulationEngine simulationEngine, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(request.Manifest)) - { - return Results.BadRequest(new { error = "Manifest is required." }); - } - - TaskPackManifest manifest; - try - { - manifest = loader.Deserialize(request.Manifest); - } - catch (Exception ex) - { - return Results.BadRequest(new { error = "Invalid manifest", detail = ex.Message }); - } - - var inputs = ConvertInputs(request.Inputs); - var planResult = planner.Plan(manifest, inputs); - if (!planResult.Success || planResult.Plan is null) - { - return Results.BadRequest(new - { - errors = planResult.Errors.Select(error => new { error.Path, error.Message }) - }); - } - - var plan = planResult.Plan; - var simulation = simulationEngine.Simulate(plan); - var response = SimulationMapper.ToResponse(plan, simulation); - return Results.Ok(response); -}).WithName("SimulateTaskPack"); - -app.MapGet("/v1/task-runner/runs/{runId}", async ( - string runId, - IPackRunStateStore stateStore, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = "runId is required." }); - } - - var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (state is null) - { - return Results.NotFound(); - } - - return Results.Ok(RunStateMapper.ToResponse(state)); -}).WithName("GetRunState"); - -app.MapGet("/", () => Results.Redirect("/openapi")); - -app.Run(); - -static IDictionary? ConvertInputs(JsonObject? node) -{ - if (node is null) - { - return null; - } - - var dictionary = new Dictionary(StringComparer.Ordinal); - foreach (var property in node) - { - dictionary[property.Key] = property.Value?.DeepClone(); - } - - return dictionary; -} - -internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs); - -internal sealed record SimulationResponse( - string PlanHash, - FailurePolicyResponse FailurePolicy, - IReadOnlyList Steps, - IReadOnlyList Outputs, - bool HasPendingApprovals); - -internal sealed record SimulationStepResponse( - string Id, - string TemplateId, - string Kind, - bool Enabled, - string Status, - string? StatusReason, - string? Uses, - string? ApprovalId, - string? GateMessage, - int? MaxParallel, - bool ContinueOnError, - IReadOnlyList Children); - -internal sealed record SimulationOutputResponse( - string Name, - string Type, - bool RequiresRuntimeValue, - string? PathExpression, - string? ValueExpression); - -internal sealed record FailurePolicyResponse(int MaxAttempts, int BackoffSeconds, bool ContinueOnError); - -internal sealed record RunStateResponse( - string RunId, - string PlanHash, - FailurePolicyResponse FailurePolicy, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyList Steps); - -internal sealed record RunStateStepResponse( - string StepId, - string Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - string Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); - -internal static class SimulationMapper -{ - public static SimulationResponse ToResponse(TaskPackPlan plan, PackRunSimulationResult result) - { - var failurePolicy = result.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var steps = result.Steps.Select(MapStep).ToList(); - var outputs = result.Outputs.Select(MapOutput).ToList(); - - return new SimulationResponse( - plan.Hash, - new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), - steps, - outputs, - result.HasPendingApprovals); - } - - private static SimulationStepResponse MapStep(PackRunSimulationNode node) - { - var children = node.Children.Select(MapStep).ToList(); - return new SimulationStepResponse( - node.Id, - node.TemplateId, - node.Kind.ToString(), - node.Enabled, - node.Status.ToString(), - node.Status.ToString() switch - { - nameof(PackRunSimulationStatus.RequiresApproval) => "requires-approval", - nameof(PackRunSimulationStatus.RequiresPolicy) => "requires-policy", - nameof(PackRunSimulationStatus.Skipped) => "condition-false", - _ => null - }, - node.Uses, - node.ApprovalId, - node.GateMessage, - node.MaxParallel, - node.ContinueOnError, - children); - } - - private static SimulationOutputResponse MapOutput(PackRunSimulationOutput output) - => new( - output.Name, - output.Type, - output.RequiresRuntimeValue, - output.Path?.Expression, - output.Expression?.Expression); -} - -internal static class RunStateMapper -{ - public static RunStateResponse ToResponse(PackRunState state) - { - var failurePolicy = state.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new RunStateStepResponse( - step.StepId, - step.Kind.ToString(), - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status.ToString(), - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason)) - .ToList(); - - return new RunStateResponse( - state.RunId, - state.PlanHash, - new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), - state.CreatedAt, - state.UpdatedAt, - steps); - } -} +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Execution.Simulation; +using StellaOps.TaskRunner.Core.Planning; +using StellaOps.TaskRunner.Core.TaskPacks; +using StellaOps.TaskRunner.Infrastructure.Execution; +using StellaOps.TaskRunner.WebService; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("TaskRunner")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return new FilePackRunStateStore(options.RunStatePath); +}); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapPost("/v1/task-runner/simulations", async ( + [FromBody] SimulationRequest request, + TaskPackManifestLoader loader, + TaskPackPlanner planner, + PackRunSimulationEngine simulationEngine, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(request.Manifest)) + { + return Results.BadRequest(new { error = "Manifest is required." }); + } + + TaskPackManifest manifest; + try + { + manifest = loader.Deserialize(request.Manifest); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "Invalid manifest", detail = ex.Message }); + } + + var inputs = ConvertInputs(request.Inputs); + var planResult = planner.Plan(manifest, inputs); + if (!planResult.Success || planResult.Plan is null) + { + return Results.BadRequest(new + { + errors = planResult.Errors.Select(error => new { error.Path, error.Message }) + }); + } + + var plan = planResult.Plan; + var simulation = simulationEngine.Simulate(plan); + var response = SimulationMapper.ToResponse(plan, simulation); + return Results.Ok(response); +}).WithName("SimulateTaskPack"); + +app.MapGet("/v1/task-runner/runs/{runId}", async ( + string runId, + IPackRunStateStore stateStore, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(runId)) + { + return Results.BadRequest(new { error = "runId is required." }); + } + + var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); + if (state is null) + { + return Results.NotFound(); + } + + return Results.Ok(RunStateMapper.ToResponse(state)); +}).WithName("GetRunState"); + +app.MapGet("/", () => Results.Redirect("/openapi")); + +app.Run(); + +static IDictionary? ConvertInputs(JsonObject? node) +{ + if (node is null) + { + return null; + } + + var dictionary = new Dictionary(StringComparer.Ordinal); + foreach (var property in node) + { + dictionary[property.Key] = property.Value?.DeepClone(); + } + + return dictionary; +} + +internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs); + +internal sealed record SimulationResponse( + string PlanHash, + FailurePolicyResponse FailurePolicy, + IReadOnlyList Steps, + IReadOnlyList Outputs, + bool HasPendingApprovals); + +internal sealed record SimulationStepResponse( + string Id, + string TemplateId, + string Kind, + bool Enabled, + string Status, + string? StatusReason, + string? Uses, + string? ApprovalId, + string? GateMessage, + int? MaxParallel, + bool ContinueOnError, + IReadOnlyList Children); + +internal sealed record SimulationOutputResponse( + string Name, + string Type, + bool RequiresRuntimeValue, + string? PathExpression, + string? ValueExpression); + +internal sealed record FailurePolicyResponse(int MaxAttempts, int BackoffSeconds, bool ContinueOnError); + +internal sealed record RunStateResponse( + string RunId, + string PlanHash, + FailurePolicyResponse FailurePolicy, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + IReadOnlyList Steps); + +internal sealed record RunStateStepResponse( + string StepId, + string Kind, + bool Enabled, + bool ContinueOnError, + int? MaxParallel, + string? ApprovalId, + string? GateMessage, + string Status, + int Attempts, + DateTimeOffset? LastTransitionAt, + DateTimeOffset? NextAttemptAt, + string? StatusReason); + +internal static class SimulationMapper +{ + public static SimulationResponse ToResponse(TaskPackPlan plan, PackRunSimulationResult result) + { + var failurePolicy = result.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; + var steps = result.Steps.Select(MapStep).ToList(); + var outputs = result.Outputs.Select(MapOutput).ToList(); + + return new SimulationResponse( + plan.Hash, + new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), + steps, + outputs, + result.HasPendingApprovals); + } + + private static SimulationStepResponse MapStep(PackRunSimulationNode node) + { + var children = node.Children.Select(MapStep).ToList(); + return new SimulationStepResponse( + node.Id, + node.TemplateId, + node.Kind.ToString(), + node.Enabled, + node.Status.ToString(), + node.Status.ToString() switch + { + nameof(PackRunSimulationStatus.RequiresApproval) => "requires-approval", + nameof(PackRunSimulationStatus.RequiresPolicy) => "requires-policy", + nameof(PackRunSimulationStatus.Skipped) => "condition-false", + _ => null + }, + node.Uses, + node.ApprovalId, + node.GateMessage, + node.MaxParallel, + node.ContinueOnError, + children); + } + + private static SimulationOutputResponse MapOutput(PackRunSimulationOutput output) + => new( + output.Name, + output.Type, + output.RequiresRuntimeValue, + output.Path?.Expression, + output.Expression?.Expression); +} + +internal static class RunStateMapper +{ + public static RunStateResponse ToResponse(PackRunState state) + { + var failurePolicy = state.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; + var steps = state.Steps.Values + .OrderBy(step => step.StepId, StringComparer.Ordinal) + .Select(step => new RunStateStepResponse( + step.StepId, + step.Kind.ToString(), + step.Enabled, + step.ContinueOnError, + step.MaxParallel, + step.ApprovalId, + step.GateMessage, + step.Status.ToString(), + step.Attempts, + step.LastTransitionAt, + step.NextAttemptAt, + step.StatusReason)) + .ToList(); + + return new RunStateResponse( + state.RunId, + state.PlanHash, + new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), + state.CreatedAt, + state.UpdatedAt, + steps); + } +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs index 551b8f8e..ba7846a0 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs @@ -1,6 +1,6 @@ -namespace StellaOps.TaskRunner.WebService; - -public sealed class TaskRunnerServiceOptions -{ - public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs"); -} +namespace StellaOps.TaskRunner.WebService; + +public sealed class TaskRunnerServiceOptions +{ + public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs"); +} diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs index b1e2731c..812f5090 100644 --- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs +++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs @@ -1,540 +1,540 @@ -using System.Collections.Concurrent; -using System.Collections.ObjectModel; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Worker.Services; - -public sealed class PackRunWorkerService : BackgroundService -{ - private const string ChildFailureReason = "child-failure"; - private const string AwaitingRetryReason = "awaiting-retry"; - - private readonly IPackRunJobDispatcher dispatcher; - private readonly PackRunProcessor processor; - private readonly PackRunWorkerOptions options; - private readonly IPackRunStateStore stateStore; - private readonly PackRunExecutionGraphBuilder graphBuilder; - private readonly PackRunSimulationEngine simulationEngine; - private readonly IPackRunStepExecutor executor; - private readonly ILogger logger; - - public PackRunWorkerService( - IPackRunJobDispatcher dispatcher, - PackRunProcessor processor, - IPackRunStateStore stateStore, - PackRunExecutionGraphBuilder graphBuilder, - PackRunSimulationEngine simulationEngine, - IPackRunStepExecutor executor, - IOptions options, - ILogger logger) - { - this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - this.processor = processor ?? throw new ArgumentNullException(nameof(processor)); - this.stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); - this.graphBuilder = graphBuilder ?? throw new ArgumentNullException(nameof(graphBuilder)); - this.simulationEngine = simulationEngine ?? throw new ArgumentNullException(nameof(simulationEngine)); - this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); - this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false); - if (context is null) - { - await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false); - continue; - } - - try - { - await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Unhandled exception while processing run {RunId}.", context.RunId); - } - } - } - - private async Task ProcessRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - logger.LogInformation("Processing pack run {RunId}.", context.RunId); - - var processorResult = await processor.ProcessNewRunAsync(context, cancellationToken).ConfigureAwait(false); - var graph = graphBuilder.Build(context.Plan); - - var state = await stateStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false); - if (state is null || !string.Equals(state.PlanHash, context.Plan.Hash, StringComparison.Ordinal)) - { - state = await CreateInitialStateAsync(context, graph, cancellationToken).ConfigureAwait(false); - } - - if (!processorResult.ShouldResumeImmediately) - { - logger.LogInformation("Run {RunId} awaiting approvals or policy gates.", context.RunId); - return; - } - - var gateUpdate = PackRunGateStateUpdater.Apply(state, graph, processorResult.ApprovalCoordinator, DateTimeOffset.UtcNow); - state = gateUpdate.State; - - if (gateUpdate.HasBlockingFailure) - { - await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); - logger.LogWarning("Run {RunId} halted because a gate failed.", context.RunId); - return; - } - - var updatedState = await ExecuteGraphAsync(context, graph, state, cancellationToken).ConfigureAwait(false); - await stateStore.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false); - - if (updatedState.Steps.Values.All(step => step.Status is PackRunStepExecutionStatus.Succeeded or PackRunStepExecutionStatus.Skipped)) - { - logger.LogInformation("Run {RunId} finished successfully.", context.RunId); - } - else - { - logger.LogInformation("Run {RunId} paused with pending work.", context.RunId); - } - } - - private async Task CreateInitialStateAsync( - PackRunExecutionContext context, - PackRunExecutionGraph graph, - CancellationToken cancellationToken) - { - var timestamp = DateTimeOffset.UtcNow; - var simulation = simulationEngine.Simulate(context.Plan); - var simulationIndex = IndexSimulation(simulation.Steps); - - var stepRecords = new Dictionary(StringComparer.Ordinal); - foreach (var step in EnumerateSteps(graph.Steps)) - { - var simulationStatus = simulationIndex.TryGetValue(step.Id, out var node) - ? node.Status - : PackRunSimulationStatus.Pending; - - var status = step.Enabled ? PackRunStepExecutionStatus.Pending : PackRunStepExecutionStatus.Skipped; - string? statusReason = null; - if (!step.Enabled) - { - statusReason = "disabled"; - } - else if (simulationStatus == PackRunSimulationStatus.RequiresApproval) - { - statusReason = "requires-approval"; - } - else if (simulationStatus == PackRunSimulationStatus.RequiresPolicy) - { - statusReason = "requires-policy"; - } - else if (simulationStatus == PackRunSimulationStatus.Skipped) - { - status = PackRunStepExecutionStatus.Skipped; - statusReason = "condition-false"; - } - - var record = new PackRunStepStateRecord( - step.Id, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - status, - Attempts: 0, - LastTransitionAt: null, - NextAttemptAt: null, - StatusReason: statusReason); - - stepRecords[step.Id] = record; - } - - var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var state = PackRunState.Create(context.RunId, context.Plan.Hash, failurePolicy, stepRecords, timestamp); - await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); - return state; - } - - private async Task ExecuteGraphAsync( - PackRunExecutionContext context, - PackRunExecutionGraph graph, - PackRunState state, - CancellationToken cancellationToken) - { - var mutable = new ConcurrentDictionary(state.Steps, StringComparer.Ordinal); - var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken); - - foreach (var step in graph.Steps) - { - var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false); - if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer) - { - break; - } - } - - var updated = new ReadOnlyDictionary(mutable); - return state with - { - UpdatedAt = DateTimeOffset.UtcNow, - Steps = updated - }; - } - - private async Task ExecuteStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - executionContext.CancellationToken.ThrowIfCancellationRequested(); - - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return StepExecutionOutcome.Continue; - } - - if (!record.Enabled) - { - return StepExecutionOutcome.Continue; - } - - if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped) - { - return StepExecutionOutcome.Continue; - } - - if (record.NextAttemptAt is { } scheduled && scheduled > DateTimeOffset.UtcNow) - { - logger.LogInformation( - "Run {RunId} step {StepId} waiting until {NextAttempt} for retry.", - executionContext.RunId, - record.StepId, - scheduled); - return StepExecutionOutcome.Defer; - } - - switch (step.Kind) - { - case PackRunStepKind.GateApproval: - case PackRunStepKind.GatePolicy: - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = DateTimeOffset.UtcNow, - NextAttemptAt = null - }; - return StepExecutionOutcome.Continue; - - case PackRunStepKind.Parallel: - return await ExecuteParallelStepAsync(step, executionContext).ConfigureAwait(false); - - case PackRunStepKind.Map: - return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false); - - case PackRunStepKind.Run: - return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false); - - default: - logger.LogWarning("Run {RunId} encountered unsupported step kind '{Kind}' for step {StepId}. Marking as skipped.", - executionContext.RunId, - step.Kind, - step.Id); - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Skipped, - StatusReason = "unsupported-kind", - LastTransitionAt = DateTimeOffset.UtcNow - }; - return StepExecutionOutcome.Continue; - } - } - - private async Task ExecuteRunStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - var record = executionContext.Steps[step.Id]; - var now = DateTimeOffset.UtcNow; - var currentState = new PackRunStepState(record.Status, record.Attempts, record.LastTransitionAt, record.NextAttemptAt); - - if (currentState.Status == PackRunStepExecutionStatus.Pending) - { - currentState = PackRunStepStateMachine.Start(currentState, now); - record = record with - { - Status = currentState.Status, - LastTransitionAt = currentState.LastTransitionAt, - NextAttemptAt = currentState.NextAttemptAt, - StatusReason = null - }; - executionContext.Steps[step.Id] = record; - } - - var result = await executor.ExecuteAsync(step, step.Parameters ?? PackRunExecutionStep.EmptyParameters, executionContext.CancellationToken).ConfigureAwait(false); - if (result.Succeeded) - { - currentState = PackRunStepStateMachine.CompleteSuccess(currentState, DateTimeOffset.UtcNow); - executionContext.Steps[step.Id] = record with - { - Status = currentState.Status, - Attempts = currentState.Attempts, - LastTransitionAt = currentState.LastTransitionAt, - NextAttemptAt = currentState.NextAttemptAt, - StatusReason = null - }; - - return StepExecutionOutcome.Continue; - } - - logger.LogWarning( - "Run {RunId} step {StepId} failed: {Error}", - executionContext.RunId, - step.Id, - result.Error ?? "unknown error"); - - var failure = PackRunStepStateMachine.RegisterFailure(currentState, DateTimeOffset.UtcNow, executionContext.FailurePolicy); - var updatedRecord = record with - { - Status = failure.State.Status, - Attempts = failure.State.Attempts, - LastTransitionAt = failure.State.LastTransitionAt, - NextAttemptAt = failure.State.NextAttemptAt, - StatusReason = result.Error - }; - - executionContext.Steps[step.Id] = updatedRecord; - - return failure.Outcome switch - { - PackRunStepFailureOutcome.Retry => StepExecutionOutcome.Defer, - PackRunStepFailureOutcome.Abort when step.ContinueOnError => StepExecutionOutcome.Continue, - PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun, - _ => StepExecutionOutcome.AbortRun - }; - } - - private async Task ExecuteParallelStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - var children = step.Children; - if (children.Count == 0) - { - MarkContainerSucceeded(step, executionContext); - return StepExecutionOutcome.Continue; - } - - var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count; - var queue = new Queue(children); - var running = new List>(maxParallel); - var outcome = StepExecutionOutcome.Continue; - var childFailureDetected = false; - - while (queue.Count > 0 || running.Count > 0) - { - while (queue.Count > 0 && running.Count < maxParallel) - { - var child = queue.Dequeue(); - running.Add(ExecuteStepAsync(child, executionContext)); - } - - var completed = await Task.WhenAny(running).ConfigureAwait(false); - running.Remove(completed); - var childOutcome = await completed.ConfigureAwait(false); - - switch (childOutcome) - { - case StepExecutionOutcome.AbortRun: - if (step.ContinueOnError) - { - childFailureDetected = true; - outcome = StepExecutionOutcome.Continue; - } - else - { - outcome = StepExecutionOutcome.AbortRun; - running.Clear(); - queue.Clear(); - } - break; - - case StepExecutionOutcome.Defer: - outcome = StepExecutionOutcome.Defer; - running.Clear(); - queue.Clear(); - break; - - default: - break; - } - - if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue) - { - break; - } - } - - if (outcome == StepExecutionOutcome.Continue) - { - if (childFailureDetected) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - else - { - MarkContainerSucceeded(step, executionContext); - } - } - else if (outcome == StepExecutionOutcome.AbortRun) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - else if (outcome == StepExecutionOutcome.Defer) - { - MarkContainerPending(step, executionContext, AwaitingRetryReason); - } - - return outcome; - } - - private async Task ExecuteMapStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - foreach (var child in step.Children) - { - var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false); - if (outcome != StepExecutionOutcome.Continue) - { - if (outcome == StepExecutionOutcome.Defer) - { - MarkContainerPending(step, executionContext, AwaitingRetryReason); - return outcome; - } - - if (!step.ContinueOnError) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - return outcome; - } - - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - } - - MarkContainerSucceeded(step, executionContext); - return StepExecutionOutcome.Continue; - } - - private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - if (record.Status == PackRunStepExecutionStatus.Succeeded) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = DateTimeOffset.UtcNow, - NextAttemptAt = null - }; - } - - private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Failed, - StatusReason = reason, - LastTransitionAt = DateTimeOffset.UtcNow - }; - } - - private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Pending, - StatusReason = reason, - LastTransitionAt = DateTimeOffset.UtcNow - }; - } - - private static Dictionary IndexSimulation(IReadOnlyList steps) - { - var result = new Dictionary(StringComparer.Ordinal); - foreach (var node in steps) - { - result[node.Id] = node; - if (node.Children.Count > 0) - { - foreach (var child in IndexSimulation(node.Children)) - { - result[child.Key] = child.Value; - } - } - } - - return result; - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - foreach (var step in steps) - { - yield return step; - if (step.Children.Count > 0) - { - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } - } - - private sealed record ExecutionContext( - string RunId, - TaskPackPlanFailurePolicy FailurePolicy, - ConcurrentDictionary Steps, - CancellationToken CancellationToken); - - private enum StepExecutionOutcome - { - Continue, - Defer, - AbortRun - } -} +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using StellaOps.TaskRunner.Core.Execution; +using StellaOps.TaskRunner.Core.Execution.Simulation; +using StellaOps.TaskRunner.Core.Planning; + +namespace StellaOps.TaskRunner.Worker.Services; + +public sealed class PackRunWorkerService : BackgroundService +{ + private const string ChildFailureReason = "child-failure"; + private const string AwaitingRetryReason = "awaiting-retry"; + + private readonly IPackRunJobDispatcher dispatcher; + private readonly PackRunProcessor processor; + private readonly PackRunWorkerOptions options; + private readonly IPackRunStateStore stateStore; + private readonly PackRunExecutionGraphBuilder graphBuilder; + private readonly PackRunSimulationEngine simulationEngine; + private readonly IPackRunStepExecutor executor; + private readonly ILogger logger; + + public PackRunWorkerService( + IPackRunJobDispatcher dispatcher, + PackRunProcessor processor, + IPackRunStateStore stateStore, + PackRunExecutionGraphBuilder graphBuilder, + PackRunSimulationEngine simulationEngine, + IPackRunStepExecutor executor, + IOptions options, + ILogger logger) + { + this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + this.processor = processor ?? throw new ArgumentNullException(nameof(processor)); + this.stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); + this.graphBuilder = graphBuilder ?? throw new ArgumentNullException(nameof(graphBuilder)); + this.simulationEngine = simulationEngine ?? throw new ArgumentNullException(nameof(simulationEngine)); + this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false); + if (context is null) + { + await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false); + continue; + } + + try + { + await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception while processing run {RunId}.", context.RunId); + } + } + } + + private async Task ProcessRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken) + { + logger.LogInformation("Processing pack run {RunId}.", context.RunId); + + var processorResult = await processor.ProcessNewRunAsync(context, cancellationToken).ConfigureAwait(false); + var graph = graphBuilder.Build(context.Plan); + + var state = await stateStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false); + if (state is null || !string.Equals(state.PlanHash, context.Plan.Hash, StringComparison.Ordinal)) + { + state = await CreateInitialStateAsync(context, graph, cancellationToken).ConfigureAwait(false); + } + + if (!processorResult.ShouldResumeImmediately) + { + logger.LogInformation("Run {RunId} awaiting approvals or policy gates.", context.RunId); + return; + } + + var gateUpdate = PackRunGateStateUpdater.Apply(state, graph, processorResult.ApprovalCoordinator, DateTimeOffset.UtcNow); + state = gateUpdate.State; + + if (gateUpdate.HasBlockingFailure) + { + await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); + logger.LogWarning("Run {RunId} halted because a gate failed.", context.RunId); + return; + } + + var updatedState = await ExecuteGraphAsync(context, graph, state, cancellationToken).ConfigureAwait(false); + await stateStore.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false); + + if (updatedState.Steps.Values.All(step => step.Status is PackRunStepExecutionStatus.Succeeded or PackRunStepExecutionStatus.Skipped)) + { + logger.LogInformation("Run {RunId} finished successfully.", context.RunId); + } + else + { + logger.LogInformation("Run {RunId} paused with pending work.", context.RunId); + } + } + + private async Task CreateInitialStateAsync( + PackRunExecutionContext context, + PackRunExecutionGraph graph, + CancellationToken cancellationToken) + { + var timestamp = DateTimeOffset.UtcNow; + var simulation = simulationEngine.Simulate(context.Plan); + var simulationIndex = IndexSimulation(simulation.Steps); + + var stepRecords = new Dictionary(StringComparer.Ordinal); + foreach (var step in EnumerateSteps(graph.Steps)) + { + var simulationStatus = simulationIndex.TryGetValue(step.Id, out var node) + ? node.Status + : PackRunSimulationStatus.Pending; + + var status = step.Enabled ? PackRunStepExecutionStatus.Pending : PackRunStepExecutionStatus.Skipped; + string? statusReason = null; + if (!step.Enabled) + { + statusReason = "disabled"; + } + else if (simulationStatus == PackRunSimulationStatus.RequiresApproval) + { + statusReason = "requires-approval"; + } + else if (simulationStatus == PackRunSimulationStatus.RequiresPolicy) + { + statusReason = "requires-policy"; + } + else if (simulationStatus == PackRunSimulationStatus.Skipped) + { + status = PackRunStepExecutionStatus.Skipped; + statusReason = "condition-false"; + } + + var record = new PackRunStepStateRecord( + step.Id, + step.Kind, + step.Enabled, + step.ContinueOnError, + step.MaxParallel, + step.ApprovalId, + step.GateMessage, + status, + Attempts: 0, + LastTransitionAt: null, + NextAttemptAt: null, + StatusReason: statusReason); + + stepRecords[step.Id] = record; + } + + var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; + var state = PackRunState.Create(context.RunId, context.Plan.Hash, failurePolicy, stepRecords, timestamp); + await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); + return state; + } + + private async Task ExecuteGraphAsync( + PackRunExecutionContext context, + PackRunExecutionGraph graph, + PackRunState state, + CancellationToken cancellationToken) + { + var mutable = new ConcurrentDictionary(state.Steps, StringComparer.Ordinal); + var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; + var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken); + + foreach (var step in graph.Steps) + { + var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false); + if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer) + { + break; + } + } + + var updated = new ReadOnlyDictionary(mutable); + return state with + { + UpdatedAt = DateTimeOffset.UtcNow, + Steps = updated + }; + } + + private async Task ExecuteStepAsync( + PackRunExecutionStep step, + ExecutionContext executionContext) + { + executionContext.CancellationToken.ThrowIfCancellationRequested(); + + if (!executionContext.Steps.TryGetValue(step.Id, out var record)) + { + return StepExecutionOutcome.Continue; + } + + if (!record.Enabled) + { + return StepExecutionOutcome.Continue; + } + + if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped) + { + return StepExecutionOutcome.Continue; + } + + if (record.NextAttemptAt is { } scheduled && scheduled > DateTimeOffset.UtcNow) + { + logger.LogInformation( + "Run {RunId} step {StepId} waiting until {NextAttempt} for retry.", + executionContext.RunId, + record.StepId, + scheduled); + return StepExecutionOutcome.Defer; + } + + switch (step.Kind) + { + case PackRunStepKind.GateApproval: + case PackRunStepKind.GatePolicy: + executionContext.Steps[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Succeeded, + StatusReason = null, + LastTransitionAt = DateTimeOffset.UtcNow, + NextAttemptAt = null + }; + return StepExecutionOutcome.Continue; + + case PackRunStepKind.Parallel: + return await ExecuteParallelStepAsync(step, executionContext).ConfigureAwait(false); + + case PackRunStepKind.Map: + return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false); + + case PackRunStepKind.Run: + return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false); + + default: + logger.LogWarning("Run {RunId} encountered unsupported step kind '{Kind}' for step {StepId}. Marking as skipped.", + executionContext.RunId, + step.Kind, + step.Id); + executionContext.Steps[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Skipped, + StatusReason = "unsupported-kind", + LastTransitionAt = DateTimeOffset.UtcNow + }; + return StepExecutionOutcome.Continue; + } + } + + private async Task ExecuteRunStepAsync( + PackRunExecutionStep step, + ExecutionContext executionContext) + { + var record = executionContext.Steps[step.Id]; + var now = DateTimeOffset.UtcNow; + var currentState = new PackRunStepState(record.Status, record.Attempts, record.LastTransitionAt, record.NextAttemptAt); + + if (currentState.Status == PackRunStepExecutionStatus.Pending) + { + currentState = PackRunStepStateMachine.Start(currentState, now); + record = record with + { + Status = currentState.Status, + LastTransitionAt = currentState.LastTransitionAt, + NextAttemptAt = currentState.NextAttemptAt, + StatusReason = null + }; + executionContext.Steps[step.Id] = record; + } + + var result = await executor.ExecuteAsync(step, step.Parameters ?? PackRunExecutionStep.EmptyParameters, executionContext.CancellationToken).ConfigureAwait(false); + if (result.Succeeded) + { + currentState = PackRunStepStateMachine.CompleteSuccess(currentState, DateTimeOffset.UtcNow); + executionContext.Steps[step.Id] = record with + { + Status = currentState.Status, + Attempts = currentState.Attempts, + LastTransitionAt = currentState.LastTransitionAt, + NextAttemptAt = currentState.NextAttemptAt, + StatusReason = null + }; + + return StepExecutionOutcome.Continue; + } + + logger.LogWarning( + "Run {RunId} step {StepId} failed: {Error}", + executionContext.RunId, + step.Id, + result.Error ?? "unknown error"); + + var failure = PackRunStepStateMachine.RegisterFailure(currentState, DateTimeOffset.UtcNow, executionContext.FailurePolicy); + var updatedRecord = record with + { + Status = failure.State.Status, + Attempts = failure.State.Attempts, + LastTransitionAt = failure.State.LastTransitionAt, + NextAttemptAt = failure.State.NextAttemptAt, + StatusReason = result.Error + }; + + executionContext.Steps[step.Id] = updatedRecord; + + return failure.Outcome switch + { + PackRunStepFailureOutcome.Retry => StepExecutionOutcome.Defer, + PackRunStepFailureOutcome.Abort when step.ContinueOnError => StepExecutionOutcome.Continue, + PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun, + _ => StepExecutionOutcome.AbortRun + }; + } + + private async Task ExecuteParallelStepAsync( + PackRunExecutionStep step, + ExecutionContext executionContext) + { + var children = step.Children; + if (children.Count == 0) + { + MarkContainerSucceeded(step, executionContext); + return StepExecutionOutcome.Continue; + } + + var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count; + var queue = new Queue(children); + var running = new List>(maxParallel); + var outcome = StepExecutionOutcome.Continue; + var childFailureDetected = false; + + while (queue.Count > 0 || running.Count > 0) + { + while (queue.Count > 0 && running.Count < maxParallel) + { + var child = queue.Dequeue(); + running.Add(ExecuteStepAsync(child, executionContext)); + } + + var completed = await Task.WhenAny(running).ConfigureAwait(false); + running.Remove(completed); + var childOutcome = await completed.ConfigureAwait(false); + + switch (childOutcome) + { + case StepExecutionOutcome.AbortRun: + if (step.ContinueOnError) + { + childFailureDetected = true; + outcome = StepExecutionOutcome.Continue; + } + else + { + outcome = StepExecutionOutcome.AbortRun; + running.Clear(); + queue.Clear(); + } + break; + + case StepExecutionOutcome.Defer: + outcome = StepExecutionOutcome.Defer; + running.Clear(); + queue.Clear(); + break; + + default: + break; + } + + if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue) + { + break; + } + } + + if (outcome == StepExecutionOutcome.Continue) + { + if (childFailureDetected) + { + MarkContainerFailure(step, executionContext, ChildFailureReason); + } + else + { + MarkContainerSucceeded(step, executionContext); + } + } + else if (outcome == StepExecutionOutcome.AbortRun) + { + MarkContainerFailure(step, executionContext, ChildFailureReason); + } + else if (outcome == StepExecutionOutcome.Defer) + { + MarkContainerPending(step, executionContext, AwaitingRetryReason); + } + + return outcome; + } + + private async Task ExecuteMapStepAsync( + PackRunExecutionStep step, + ExecutionContext executionContext) + { + foreach (var child in step.Children) + { + var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false); + if (outcome != StepExecutionOutcome.Continue) + { + if (outcome == StepExecutionOutcome.Defer) + { + MarkContainerPending(step, executionContext, AwaitingRetryReason); + return outcome; + } + + if (!step.ContinueOnError) + { + MarkContainerFailure(step, executionContext, ChildFailureReason); + return outcome; + } + + MarkContainerFailure(step, executionContext, ChildFailureReason); + } + } + + MarkContainerSucceeded(step, executionContext); + return StepExecutionOutcome.Continue; + } + + private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext) + { + if (!executionContext.Steps.TryGetValue(step.Id, out var record)) + { + return; + } + + if (record.Status == PackRunStepExecutionStatus.Succeeded) + { + return; + } + + executionContext.Steps[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Succeeded, + StatusReason = null, + LastTransitionAt = DateTimeOffset.UtcNow, + NextAttemptAt = null + }; + } + + private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason) + { + if (!executionContext.Steps.TryGetValue(step.Id, out var record)) + { + return; + } + + executionContext.Steps[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Failed, + StatusReason = reason, + LastTransitionAt = DateTimeOffset.UtcNow + }; + } + + private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason) + { + if (!executionContext.Steps.TryGetValue(step.Id, out var record)) + { + return; + } + + executionContext.Steps[step.Id] = record with + { + Status = PackRunStepExecutionStatus.Pending, + StatusReason = reason, + LastTransitionAt = DateTimeOffset.UtcNow + }; + } + + private static Dictionary IndexSimulation(IReadOnlyList steps) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var node in steps) + { + result[node.Id] = node; + if (node.Children.Count > 0) + { + foreach (var child in IndexSimulation(node.Children)) + { + result[child.Key] = child.Value; + } + } + } + + return result; + } + + private static IEnumerable EnumerateSteps(IReadOnlyList steps) + { + foreach (var step in steps) + { + yield return step; + if (step.Children.Count > 0) + { + foreach (var child in EnumerateSteps(step.Children)) + { + yield return child; + } + } + } + } + + private sealed record ExecutionContext( + string RunId, + TaskPackPlanFailurePolicy FailurePolicy, + ConcurrentDictionary Steps, + CancellationToken CancellationToken); + + private enum StepExecutionOutcome + { + Continue, + Defer, + AbortRun + } +} diff --git a/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj index 9b3852da..06107197 100644 --- a/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj @@ -1,22 +1,22 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file