up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -16,9 +16,10 @@
* **Scannerowned SBOMs.** We generate our own BOMs; we do not warehouse thirdparty SBOM content (we can **link** to attested SBOMs). * **Scannerowned SBOMs.** We generate our own BOMs; we do not warehouse thirdparty SBOM content (we can **link** to attested SBOMs).
* **Deterministic evidence.** Facts come from package DBs, installed metadata, linkers, and verified attestations; no fuzzy guessing in the core. * **Deterministic evidence.** Facts come from package DBs, installed metadata, linkers, and verified attestations; no fuzzy guessing in the core.
* **Per-layer caching.** Cache fragments by **layer digest** and compose image SBOMs via **CycloneDX BOM-Link** / **SPDX ExternalRef**. * **Per-layer caching.** Cache fragments by **layer digest** and compose image SBOMs via **CycloneDX BOM-Link** / **SPDX ExternalRef**.
* **Inventory vs Usage.** Always record the full **inventory** of what exists; separately present **usage** (entrypoint closure + loaded libs). * **Inventory vs Usage.** Always record the full **inventory** of what exists; separately present **usage** (entrypoint closure + loaded libs).
* **Backend decides.** PASS/FAIL is produced by **Policy** + **VEX** + **Advisories**. The scanner reports facts. * **Backend decides.** PASS/FAIL is produced by **Policy** + **VEX** + **Advisories**. The scanner reports facts.
* **Attest or it didnt happen.** Every export is signed as **in-toto/DSSE** and logged in **Rekor v2**. * **VEX-first triage UX.** Operators triage by artifact with evidence-first cards, VEX decisioning, and immutable audit bundles; see `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
* **Attest or it didn't happen.** Every export is signed as **in-toto/DSSE** and logged in **Rekor v2**.
* **Hybrid reachability attestations.** Every reachability graph ships with a graph-level DSSE (mandatory) plus optional edge-bundle DSSEs for runtime/init/contested edges; Policy/Signals consume graph DSSE as baseline and edge bundles for quarantine/disputes. * **Hybrid reachability attestations.** Every reachability graph ships with a graph-level DSSE (mandatory) plus optional edge-bundle DSSEs for runtime/init/contested edges; Policy/Signals consume graph DSSE as baseline and edge bundles for quarantine/disputes.
* **Sovereign-ready.** Cloud is used only for licensing and optional endorsement; everything else is first-party and self-hostable. * **Sovereign-ready.** Cloud is used only for licensing and optional endorsement; everything else is first-party and self-hostable.
* **Competitive clarity.** Moats: deterministic replay, hybrid reachability proofs, lattice VEX, sovereign crypto, proof graph; see `docs/market/competitive-landscape.md`. * **Competitive clarity.** Moats: deterministic replay, hybrid reachability proofs, lattice VEX, sovereign crypto, proof graph; see `docs/market/competitive-landscape.md`.
@@ -46,7 +47,7 @@
| **Attestor** | `stellaops/attestor` | Posts DSSE bundles to **Rekor v2**; verification endpoints. | Stateless; HPA by QPS. | | **Attestor** | `stellaops/attestor` | Posts DSSE bundles to **Rekor v2**; verification endpoints. | Stateless; HPA by QPS. |
| **Authority** | `stellaops/authority` | Onprem OIDC issuing **shortlived OpToks** with DPoP/mTLS sender constraint. | HA behind LB. | | **Authority** | `stellaops/authority` | Onprem OIDC issuing **shortlived OpToks** with DPoP/mTLS sender constraint. | HA behind LB. |
| **Zastava** (Runtime) | `stellaops/zastava` | Runtime inspector/enforcer (observer + optional Admission Webhook). | DaemonSet + Webhook. | | **Zastava** (Runtime) | `stellaops/zastava` | Runtime inspector/enforcer (observer + optional Admission Webhook). | DaemonSet + Webhook. |
| **Web UI** | `stellaops/ui` | Angular app for scans, diffs, policy, VEX, **Scheduler**, **Notify**, runtime, reports. | Stateless. | | **Web UI** | `stellaops/ui` | Angular app for scans, diffs, policy, VEX, vulnerability triage (artifact-first), audit bundles, **Scheduler**, **Notify**, runtime, reports. | Stateless. |
| **StellaOps.Cli** | `stellaops/cli` | CLI for init/scan/export/diff/policy/report/verify; Buildx helper; **schedule** and **notify** verbs. | Local/CI. | | **StellaOps.Cli** | `stellaops/cli` | CLI for init/scan/export/diff/policy/report/verify; Buildx helper; **schedule** and **notify** verbs. | Local/CI. |
### 1.2 Thirdparty (selfhosted) ### 1.2 Thirdparty (selfhosted)

View File

@@ -6,7 +6,7 @@
- **Working directory:** `src/Web/StellaOps.Web` - **Working directory:** `src/Web/StellaOps.Web`
## Dependencies & Concurrency ## Dependencies & Concurrency
- Upstream sprints: SPRINT_0209_0001_0001_ui_i (UI I), SPRINT_0210_0001_0002_ui_ii (UI II - VEX tab). - Upstream sprints (archived): `docs/implplan/archived/SPRINT_0209_0001_0001_ui_i.md` (UI I), `docs/implplan/archived/SPRINT_0210_0001_0002_ui_ii.md` (UI II - VEX tab).
- Backend dependencies: Vuln Explorer APIs (`/v1/findings`, `/v1/vex-decisions`), Attestor service, Export Center. - Backend dependencies: Vuln Explorer APIs (`/v1/findings`, `/v1/vex-decisions`), Attestor service, Export Center.
- Parallel tracks: Can run alongside UI II/III for shared component work. - Parallel tracks: Can run alongside UI II/III for shared component work.
- Blockers to flag: VEX decision API schema finalization, Attestation viewer predicates. - Blockers to flag: VEX decision API schema finalization, Attestation viewer predicates.
@@ -18,59 +18,58 @@
- `docs/modules/ui/architecture.md` - `docs/modules/ui/architecture.md`
- `docs/modules/vuln-explorer/architecture.md` - `docs/modules/vuln-explorer/architecture.md`
- `docs/modules/vex-lens/architecture.md` - `docs/modules/vex-lens/architecture.md`
- `docs/product-advisories/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md` (canonical) - `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md` (canonical)
- `docs/product-advisories/27-Nov-2025 - Explainability Layer for Vulnerability Verdicts.md` - `docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - Explainability Layer for Vulnerability Verdicts.md`
- `docs/schemas/vex-decision.schema.json` - `docs/schemas/vex-decision.schema.json`
- `docs/schemas/audit-bundle-index.schema.json` - `docs/schemas/audit-bundle-index.schema.json`
## Delivery Tracker ## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 1 | UI-TRIAGE-01-001 | TODO | Path corrected; work in `src/Web/StellaOps.Web` | UI Guild (src/Web/StellaOps.Web) | Create Artifacts List view with columns: Artifact, Type, Environment(s), Open/Total vulns, Max severity, Attestations badge, Last scan. Include sorting, filtering, and "View vulnerabilities" primary action. | | 1 | UI-TRIAGE-01-001 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts` | UI Guild (src/Web/StellaOps.Web) | Create Artifacts List view with columns: Artifact, Type, Environment(s), Open/Total vulns, Max severity, Attestations badge, Last scan. Include sorting, filtering, and "View vulnerabilities" primary action. |
| 2 | UI-TRIAGE-01-002 | TODO | Depends on task 1 | UI Guild (src/Web/StellaOps.Web) | Build Vulnerability Workspace split layout: left panel with finding cards (CVE, package, severity, path), right panel with Explainability tabs (Overview, Reachability, Policy, Attestations). | | 2 | UI-TRIAGE-01-002 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts` | UI Guild (src/Web/StellaOps.Web) | Build Vulnerability Workspace split layout: left panel with finding cards (CVE, package, severity, path), right panel with Explainability tabs (Overview, Reachability, Policy, Attestations). |
| 3 | UI-TRIAGE-01-003 | TODO | Depends on task 2 | UI Guild (src/Web/StellaOps.Web) | Implement evidence-first Finding Card component with severity badge, package info, location path, and primary actions (Fix PR, VEX, Attach Evidence). Include `New`, `VEX: Not affected`, `Policy: blocked` badges. | | 3 | UI-TRIAGE-01-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web/StellaOps.Web) | Implement evidence-first Finding Card component with severity badge, package info, location path, and primary actions (Fix PR, VEX, Attach Evidence). Include `New`, `VEX: Not affected`, `Policy: blocked` badges. |
| 4 | UI-TRIAGE-01-004 | TODO | Depends on task 3 | UI Guild (src/Web/StellaOps.Web) | Build Explainability Panel Overview tab: title, severity, package/version, scanner+DB date, finding history timeline, current VEX decision summary. | | 4 | UI-TRIAGE-01-004 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web/StellaOps.Web) | Build Explainability Panel Overview tab: title, severity, package/version, scanner+DB date, finding history timeline, current VEX decision summary. |
| 5 | UI-TRIAGE-01-005 | TODO | Depends on task 4 | UI Guild (src/Web/StellaOps.Web) | Build Explainability Panel Reachability tab: call path visualization, module list, runtime usage indicators (when available from scanner). | | 5 | UI-TRIAGE-01-005 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web.StellaOps.Web) | Build Explainability Panel Reachability tab: call path visualization, module list, runtime usage indicators (when available from scanner). |
| 6 | UI-TRIAGE-01-006 | TODO | Depends on task 4 | UI Guild (src/Web/StellaOps.Web) | Build Explainability Panel Policy tab: policy evaluation result, gate details with "this gate failed because..." explanation, links to gate definitions. | | 6 | UI-TRIAGE-01-006 | DONE | Evidence: `src/Web.StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web.StellaOps.Web) | Build Explainability Panel Policy tab: policy evaluation result, gate details with "this gate failed because..." explanation, links to gate definitions. |
| 7 | UI-TRIAGE-01-007 | TODO | Depends on task 4 | UI Guild (src/Web/StellaOps.Web) | Build Explainability Panel Attestations tab: list attestations mentioning artifact/vulnerabilityId/scan with type, subject, predicate, signer, verified badge. | | 7 | UI-TRIAGE-01-007 | DONE | Evidence: `src/Web.StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web.StellaOps.Web) | Build Explainability Panel Attestations tab: list attestations mentioning artifact/vulnerabilityId/scan with type, subject, predicate, signer, verified badge. |
| 8 | UI-VEX-02-001 | TODO | Depends on task 3 | UI Guild; Excititor Guild (src/Web/StellaOps.Web) | Create VEX Modal component with status radio buttons (Not Affected, Affected-mitigated, Affected-unmitigated, Fixed), justification type select, justification text area. | | 8 | UI-VEX-02-001 | DONE | Evidence: `src/Web.StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts` | UI Guild; Excititor Guild (src/Web.StellaOps.Web) | Create VEX Modal component with status radio buttons (Not Affected, Affected-mitigated, Affected-unmitigated, Fixed), justification type select, justification text area. |
| 9 | UI-VEX-02-002 | TODO | Depends on task 8 | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal scope section: environments multi-select, projects multi-select with clear scope preview. | | 9 | UI-VEX-02-002 | DONE | Evidence: `src/Web.StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts` | UI Guild (src/Web.StellaOps.Web) | Add VEX Modal scope section: environments multi-select, projects multi-select with clear scope preview. |
| 10 | UI-VEX-02-003 | TODO | Depends on task 9 | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal validity section: notBefore date (default now), notAfter date with expiry recommendations and warnings for long durations. | | 10 | UI-VEX-02-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html` | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal validity section: notBefore date (default now), notAfter date with expiry recommendations and warnings for long durations. |
| 11 | UI-VEX-02-004 | TODO | Depends on task 10 | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal evidence section: add links (PR, ticket, doc, commit), attach attestation picker, evidence preview list with remove action. | | 11 | UI-VEX-02-004 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html` | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal evidence section: add links (PR, ticket, doc, commit), attach attestation picker, evidence preview list with remove action. |
| 12 | UI-VEX-02-005 | TODO | Depends on task 11 | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal review section: summary preview of VEX statement to be created, "Will generate signed attestation" indicator, View raw JSON toggle for power users. | | 12 | UI-VEX-02-005 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html` | UI Guild (src/Web/StellaOps.Web) | Add VEX Modal review section: summary preview of VEX statement to be created, "Will generate signed attestation" indicator, View raw JSON toggle for power users. |
| 13 | UI-VEX-02-006 | TODO | Depends on task 12 | UI Guild (src/Web/StellaOps.Web) | Wire VEX Modal to backend: POST /vex-decisions on save, handle success/error states, update finding card VEX badge on completion. | | 13 | UI-VEX-02-006 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts`; `src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts` | UI Guild (src/Web/StellaOps.Web) | Wire VEX Modal to backend: POST /v1/vex-decisions on save, handle success/error states, update finding card VEX badge on completion. |
| 14 | UI-VEX-02-007 | TODO | Depends on task 13 | UI Guild (src/Web/StellaOps.Web) | Add bulk VEX action: multi-select findings from list, open VEX modal with bulk context, apply decision to all selected findings. | | 14 | UI-VEX-02-007 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts`; `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts` | UI Guild (src/Web/StellaOps.Web) | Add bulk VEX action: multi-select findings from list, open VEX modal with bulk context, apply decision to all selected findings. |
| 15 | UI-ATT-03-001 | TODO | Depends on task 7 | UI Guild; Attestor Guild (src/Web/StellaOps.Web) | Create Attestations View per artifact: table with Type, Subject, Predicate type, Scanner/policy engine, Signer (keyId + trusted badge), Created at, Verified status. | | 15 | UI-ATT-03-001 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild; Attestor Guild (src/Web/StellaOps.Web) | Create Attestations View per artifact: table with Type, Subject, Predicate type, Scanner/policy engine, Signer (keyId + trusted badge), Created at, Verified status. |
| 16 | UI-ATT-03-002 | TODO | Depends on task 15 | UI Guild (src/Web/StellaOps.Web) | Build Attestation Detail modal: header (statement id, subject, signer), predicate preview (vuln scan counts, SBOM bomRef, VEX decision status), verify command snippet. | | 16 | UI-ATT-03-002 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts` | UI Guild (src/Web/StellaOps.Web) | Build Attestation Detail modal: header (statement id, subject, signer), predicate preview (vuln scan counts, SBOM bomRef, VEX decision status), verify command snippet. |
| 17 | UI-ATT-03-003 | TODO | Depends on task 16 | UI Guild (src/Web/StellaOps.Web) | Add "Signed evidence" pill to finding cards: clicking opens attestation detail modal, shows human-readable JSON view. | | 17 | UI-ATT-03-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild (src/Web/StellaOps.Web) | Add "Signed evidence" pill to finding cards: clicking opens attestation detail modal, shows human-readable JSON view. |
| 18 | UI-GATE-04-001 | TODO | Depends on task 6 | UI Guild; Policy Guild (src/Web/StellaOps.Web) | Create Policy & Gating View: matrix of gates vs subject types (CI Build, Registry Admission, Runtime Admission), rule descriptions, last evaluation stats. | | 18 | UI-GATE-04-001 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html` | UI Guild; Policy Guild (src/Web/StellaOps.Web) | Create Policy & Gating View: matrix of gates vs subject types (CI Build, Registry Admission, Runtime Admission), rule descriptions, last evaluation stats. |
| 19 | UI-GATE-04-002 | TODO | Depends on task 18 | UI Guild (src/Web/StellaOps.Web) | Add gate drill-down: recent evaluations list, artifact links, policy attestation links, condition failure explanations. | | 19 | UI-GATE-04-002 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts` | UI Guild (src/Web/StellaOps.Web) | Add gate drill-down: recent evaluations list, artifact links, policy attestation links, condition failure explanations. |
| 20 | UI-GATE-04-003 | TODO | Depends on task 19 | UI Guild (src/Web/StellaOps.Web) | Add "Ready to deploy" badge on artifact cards when all gates pass and required attestations verified. | | 20 | UI-GATE-04-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html` | UI Guild (src/Web/StellaOps.Web) | Add "Ready to deploy" badge on artifact cards when all gates pass and required attestations verified. |
| 21 | UI-AUDIT-05-001 | TODO | Depends on task 1 | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Create "Create immutable audit bundle" button on Artifact page, Pipeline run detail, and Policy evaluation detail views. | | 21 | UI-AUDIT-05-001 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html`; `src/Web/StellaOps.Web/src/app/features/orchestrator/orchestrator-job-detail.component.ts`; `src/Web/StellaOps.Web/src/app/features/policy-studio/explain/policy-explain.component.ts` | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Create "Create immutable audit bundle" button on Artifact page, Pipeline run detail, and Policy evaluation detail views. |
| 22 | UI-AUDIT-05-002 | TODO | Depends on task 21 | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Build Audit Bundle creation wizard: subject artifact+digest selection, time window picker, content checklist (Vuln reports, SBOM, VEX, Policy evals, Attestations). | | 22 | UI-AUDIT-05-002 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts` | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Build Audit Bundle creation wizard: subject artifact+digest selection, time window picker, content checklist (Vuln reports, SBOM, VEX, Policy evals, Attestations). |
| 23 | UI-AUDIT-05-003 | TODO | Depends on task 22 | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Wire audit bundle creation to POST /audit-bundles, show progress, display bundle ID, hash, download button, and OCI reference on completion. | | 23 | UI-AUDIT-05-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts`; `src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts` | UI Guild; Export Center Guild (src/Web/StellaOps.Web) | Wire audit bundle creation to POST /v1/audit-bundles, show progress, display bundle ID, hash, download button, and OCI reference on completion. |
| 24 | UI-AUDIT-05-004 | TODO | Depends on task 23 | UI Guild (src/Web/StellaOps.Web) | Add audit bundle history view: list previously created bundles with bundleId, createdAt, subject, download/view actions. | | 24 | UI-AUDIT-05-004 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts` | UI Guild (src/Web/StellaOps.Web) | Add audit bundle history view: list previously created bundles with bundleId, createdAt, subject, download/view actions. |
| 25 | API-VEX-06-001 | TODO | - | API Guild (src/VulnExplorer) | Implement POST /v1/vex-decisions endpoint with VexDecisionDto request/response per schema, validation, attestation generation trigger. | | 25 | API-VEX-06-001 | BLOCKED | Blocked: needs `SCHEMA-08-001` + `DTO-09-001` sign-off/implementation in `src/VulnExplorer` | API Guild (src/VulnExplorer) | Implement POST /v1/vex-decisions endpoint with VexDecisionDto request/response per schema, validation, attestation generation trigger. |
| 26 | API-VEX-06-002 | TODO | API-VEX-06-001 | API Guild (src/VulnExplorer) | Implement PATCH /v1/vex-decisions/{id} for updating existing decisions with supersedes tracking. | | 26 | API-VEX-06-002 | BLOCKED | Blocked: depends on API-VEX-06-001 | API Guild (src/VulnExplorer) | Implement PATCH /v1/vex-decisions/{id} for updating existing decisions with supersedes tracking. |
| 27 | API-VEX-06-003 | TODO | API-VEX-06-002 | API Guild (src/VulnExplorer) | Implement GET /v1/vex-decisions with filters for vulnerabilityId, subject, status, scope, validFor. | | 27 | API-VEX-06-003 | BLOCKED | Blocked: depends on API-VEX-06-002 | API Guild (src/VulnExplorer) | Implement GET /v1/vex-decisions with filters for vulnerabilityId, subject, status, scope, validFor. |
| 28 | API-AUDIT-07-001 | TODO | - | API Guild (src/ExportCenter) | Implement POST /v1/audit-bundles endpoint with bundle creation, index generation, ZIP/OCI artifact production. | | 28 | API-AUDIT-07-001 | BLOCKED | Blocked: needs `SCHEMA-08-003` + Export Center job/ZIP/OCI implementation in `src/ExportCenter` | API Guild (src/ExportCenter) | Implement POST /v1/audit-bundles endpoint with bundle creation, index generation, ZIP/OCI artifact production. |
| 29 | API-AUDIT-07-002 | TODO | API-AUDIT-07-001 | API Guild (src/ExportCenter) | Implement GET /v1/audit-bundles/{bundleId} for bundle download with integrity verification. | | 29 | API-AUDIT-07-002 | BLOCKED | Blocked: depends on API-AUDIT-07-001 | API Guild (src/ExportCenter) | Implement GET /v1/audit-bundles/{bundleId} for bundle download with integrity verification. |
| 30 | SCHEMA-08-001 | TODO | - | Platform Guild | Create docs/schemas/vex-decision.schema.json with JSON Schema 2020-12 definition per advisory. | | 30 | SCHEMA-08-001 | BLOCKED | Blocked: Action Tracker #1 (Platform + Excititor schema review/sign-off) | Platform Guild | Review and finalize `docs/schemas/vex-decision.schema.json` (JSON Schema 2020-12) per advisory; confirm examples and versioning. |
| 31 | SCHEMA-08-002 | TODO | SCHEMA-08-001 | Platform Guild | Create docs/schemas/attestation-vuln-scan.schema.json for vulnerability scan attestation predicate. | | 31 | SCHEMA-08-002 | BLOCKED | Blocked: Action Tracker #2 (Attestor predicate review/sign-off) | Platform Guild | Review and finalize `docs/schemas/attestation-vuln-scan.schema.json` predicate schema; align predicateType URI and required fields. |
| 32 | SCHEMA-08-003 | TODO | SCHEMA-08-002 | Platform Guild | Create docs/schemas/audit-bundle-index.schema.json for audit bundle manifest structure. | | 32 | SCHEMA-08-003 | BLOCKED | Blocked: Action Tracker #3 (Export Center format review/sign-off) | Platform Guild | Review and finalize `docs/schemas/audit-bundle-index.schema.json` for audit bundle manifest structure; confirm stable IDs and deterministic ordering guidance. |
| 33 | DTO-09-001 | TODO | SCHEMA-08-001 | API Guild | Create VexDecisionDto, SubjectRefDto, EvidenceRefDto, VexScopeDto, ValidForDto C# DTOs per advisory. | | 33 | DTO-09-001 | BLOCKED | Blocked: depends on SCHEMA-08-001 finalization | API Guild | Create VexDecisionDto, SubjectRefDto, EvidenceRefDto, VexScopeDto, ValidForDto C# DTOs per advisory. |
| 34 | DTO-09-002 | TODO | SCHEMA-08-002 | API Guild | Create VulnScanAttestationDto, AttestationSubjectDto, VulnScanPredicateDto C# DTOs per advisory. | | 34 | DTO-09-002 | BLOCKED | Blocked: depends on SCHEMA-08-002 finalization | API Guild | Create VulnScanAttestationDto, AttestationSubjectDto, VulnScanPredicateDto C# DTOs per advisory. |
| 35 | DTO-09-003 | TODO | SCHEMA-08-003 | API Guild | Create AuditBundleIndexDto, BundleArtifactDto, BundleVexDecisionEntryDto C# DTOs per advisory. | | 35 | DTO-09-003 | BLOCKED | Blocked: depends on SCHEMA-08-003 finalization | API Guild | Create AuditBundleIndexDto, BundleArtifactDto, BundleVexDecisionEntryDto C# DTOs per advisory. |
| 36 | TS-10-001 | TODO | Schemas not present locally; path corrected to `src/Web/StellaOps.Web` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for VexDecision, SubjectRef, EvidenceRef, VexScope, ValidFor per advisory. | | 36 | TS-10-001 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts`; `src/Web/StellaOps.Web/src/app/core/api/vex-decisions.models.ts` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for VexDecision, SubjectRef, EvidenceRef, VexScope, ValidFor per advisory. |
| 37 | TS-10-002 | TODO | Schemas not present locally; path corrected to `src/Web/StellaOps.Web` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for VulnScanAttestation, AttestationSubject, VulnScanPredicate per advisory. | | 37 | TS-10-002 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/core/api/attestation-vuln-scan.models.ts` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for VulnScanAttestation, AttestationSubject, VulnScanPredicate per advisory. |
| 38 | TS-10-003 | TODO | Schemas not present locally; path corrected to `src/Web/StellaOps.Web` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for AuditBundleIndex, BundleArtifact, BundleVexDecisionEntry per advisory. | | 38 | TS-10-003 | DONE | Evidence: `src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts` | UI Guild (src/Web/StellaOps.Web) | Create TypeScript interfaces for AuditBundleIndex, BundleArtifact, BundleVexDecisionEntry per advisory. |
| 39 | DOC-11-001 | TODO | Product advisory doc sync | Docs Guild (docs/) | Update high-level positioning for VEX-first triage: refresh docs/key-features.md and docs/07_HIGH_LEVEL_ARCHITECTURE.md with UX/audit bundle narrative; link 28-Nov-2025 advisory. | | 39 | DOC-11-001 | DONE | Evidence: `docs/key-features.md`; `docs/07_HIGH_LEVEL_ARCHITECTURE.md` | Docs Guild (docs/) | Update high-level positioning for VEX-first triage: refresh docs/key-features.md and docs/07_HIGH_LEVEL_ARCHITECTURE.md with UX/audit bundle narrative; link `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`. |
| 40 | DOC-11-002 | TODO | DOC-11-001 | Docs Guild; UI Guild | Update docs/modules/ui/architecture.md with triage workspace + VEX modal flows; add schema links and advisory cross-references. | | 40 | DOC-11-002 | DONE | Evidence: `docs/modules/ui/architecture.md` | Docs Guild; UI Guild | Update docs/modules/ui/architecture.md with triage workspace + VEX modal flows; add schema links and advisory cross-references. |
| 41 | DOC-11-003 | TODO | DOC-11-001 | Docs Guild; Vuln Explorer Guild; Export Center Guild | Update docs/modules/vuln-explorer/architecture.md and docs/modules/export-center/architecture.md with VEX decision/audit bundle API surfaces and schema references. | | 41 | DOC-11-003 | DONE | Evidence: `docs/modules/vuln-explorer/architecture.md`; `docs/modules/export-center/architecture.md` | Docs Guild; Vuln Explorer Guild; Export Center Guild | Update docs/modules/vuln-explorer/architecture.md and docs/modules/export-center/architecture.md with VEX decision/audit bundle API surfaces and schema references. |
| 42 | TRIAGE-GAPS-215-042 | TODO | Close VT1VT10 from `31-Nov-2025 FINDINGS.md`; depends on schema publication and UI workspace bootstrap | UI Guild · Platform Guild | Remediate VT1VT10: publish signed schemas + canonical JSON, enforce evidence linkage (graph/policy/attestations), tenant/RBAC controls, deterministic ordering/pagination, a11y standards, offline triage-kit exports, supersedes/conflict rules, attestation verification UX, redaction policy, UX telemetry/SLIs with alerts. | | 42 | TRIAGE-GAPS-215-042 | BLOCKED | Blocked: depends on schema publication (`SCHEMA-08-*`) + real findings/VEX/audit APIs + telemetry contract | UI Guild · Platform Guild | Remediate VT1VT10: publish signed schemas + canonical JSON, enforce evidence linkage (graph/policy/attestations), tenant/RBAC controls, deterministic ordering/pagination, a11y standards, offline triage-kit exports, supersedes/conflict rules, attestation verification UX, redaction policy, UX telemetry/SLIs with alerts. |
| 43 | UI-PROOF-VEX-0215-010 | TODO | Proof-linked VEX UI spec; depends on VexLens/Findings APIs and DSSE headers | UI Guild; VexLens Guild; Policy Guild | Implement proof-linked Not Affected badge/drawer: scoped endpoints + tenant headers, cache/staleness policy, client integrity checks, failure/offline UX, evidence precedence, telemetry schema/privacy, signed permalinks, revision reconciliation, fixtures/tests. | | 43 | UI-PROOF-VEX-0215-010 | BLOCKED | Blocked: depends on VexLens/Findings APIs + DSSE headers + caching/integrity rules | UI Guild; VexLens Guild; Policy Guild | Implement proof-linked Not Affected badge/drawer: scoped endpoints + tenant headers, cache/staleness policy, client integrity checks, failure/offline UX, evidence precedence, telemetry schema/privacy, signed permalinks, revision reconciliation, fixtures/tests. |
| 44 | TTE-GAPS-0215-011 | TODO | TTE metric advisory; align with telemetry core sprint | UI Guild; Telemetry Guild | Close TTE1TTE10: publish tte-event schema, proof eligibility rules, sampling/bot filters, per-surface SLO/error budgets, required indexes/streaming SLAs, offline-kit handling, alert/runbook, release regression gate, and a11y/viewport tests. | | 44 | TTE-GAPS-0215-011 | BLOCKED | Blocked: depends on telemetry core sprint (TTE schema + SLIs/SLOs) | UI Guild; Telemetry Guild | Close TTE1TTE10: publish tte-event schema, proof eligibility rules, sampling/bot filters, per-surface SLO/error budgets, required indexes/streaming SLAs, offline-kit handling, alert/runbook, release regression gate, and a11y/viewport tests. |
## Wave Coordination ## Wave Coordination
- **Wave A (Schemas & DTOs):** SCHEMA-08-*, DTO-09-*, TS-10-* - Foundation work - **Wave A (Schemas & DTOs):** SCHEMA-08-*, DTO-09-*, TS-10-* - Foundation work
@@ -80,7 +79,7 @@
## Wave Detail Snapshots ## Wave Detail Snapshots
### Wave A - Schemas & Types ### Wave A - Schemas & Types
- Duration: 2-3 days - Duration: 2-3 days
- Deliverables: JSON schemas in docs/schemas/, C# DTOs in src/VulnExplorer, TypeScript interfaces in src/UI - Deliverables: JSON schemas in docs/schemas/, C# DTOs in src/VulnExplorer, TypeScript interfaces in src/Web/StellaOps.Web
- Exit criteria: Schemas validate, DTOs compile, TS interfaces pass type checks - Exit criteria: Schemas validate, DTOs compile, TS interfaces pass type checks
### Wave B - Backend APIs ### Wave B - Backend APIs
@@ -112,7 +111,8 @@
| 2 | Confirm attestation predicate types with Attestor team | API Guild | 2025-12-03 | TODO | | 2 | Confirm attestation predicate types with Attestor team | API Guild | 2025-12-03 | TODO |
| 3 | Review audit bundle format with Export Center team | API Guild | 2025-12-04 | TODO | | 3 | Review audit bundle format with Export Center team | API Guild | 2025-12-04 | TODO |
| 4 | Accessibility review of VEX modal with Accessibility Guild | UI Guild | 2025-12-09 | TODO | | 4 | Accessibility review of VEX modal with Accessibility Guild | UI Guild | 2025-12-09 | TODO |
| 5 | Align UI work to canonical workspace `src/Web/StellaOps.Web`; ensure fixtures regenerated for triage/VEX components | DevEx · UI Guild | 2025-12-06 | TODO | | 5 | Align UI work to canonical workspace `src/Web/StellaOps.Web` | DevEx · UI Guild | 2025-12-06 | DONE |
| 6 | Regenerate deterministic fixtures for triage/VEX components (tests/e2e/offline-kit) | DevEx · UI Guild | 2025-12-13 | TODO |
## Decisions & Risks ## Decisions & Risks
| Risk | Impact | Mitigation / Next Step | | Risk | Impact | Mitigation / Next Step |
@@ -121,20 +121,22 @@
| Attestation service not ready | UI-ATT-* tasks blocked | Mock attestation data; feature flag attestation views | | Attestation service not ready | UI-ATT-* tasks blocked | Mock attestation data; feature flag attestation views |
| Export Center capacity | Audit bundle generation slow | Async generation with progress; queue management | | Export Center capacity | Audit bundle generation slow | Async generation with progress; queue management |
| Bulk VEX operations performance | UI-VEX-02-007 slow for large selections | Batch API endpoint; pagination; background processing | | Bulk VEX operations performance | UI-VEX-02-007 slow for large selections | Batch API endpoint; pagination; background processing |
| Advisory doc sync lag | Docs drift from UX/API decisions | Track DOC-11-* tasks; block release sign-off until docs updated | | Advisory doc sync lag | Docs drift from UX/API decisions | DOC-11-* DONE; re-review docs when schemas/APIs finalize |
| UI workspace path corrected | UI-TRIAGE-* and TS-10-* tasks proceed in `src/Web/StellaOps.Web`; fixtures still needed | Keep work in canonical workspace; regenerate deterministic fixtures before merge | | UI workspace path corrected | Risk of drift if non-canonical UI workspace used | Keep work in canonical workspace `src/Web/StellaOps.Web`; regenerate deterministic fixtures before release |
| VT gaps (VT1VT10) | Missing schemas/evidence linkage/determinism/a11y/offline parity could ship broken triage UX | Track TRIAGE-GAPS-215-042; publish schemas, enforce RBAC/tenant binding, redaction, deterministic ordering, offline triage-kit, attestation verification UX, and UX telemetry before release | | VT gaps (VT1VT10) | Missing schemas/evidence linkage/determinism/a11y/offline parity could ship broken triage UX | Track TRIAGE-GAPS-215-042; publish schemas, enforce RBAC/tenant binding, redaction, deterministic ordering, offline triage-kit, attestation verification UX, and UX telemetry before release |
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-11-28 | Sprint created from product advisory `28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`. 38 tasks defined across 5 UI task groups, 2 API task groups, 3 schema tasks, 3 DTO tasks, 3 TS interface tasks. | Project mgmt | | 2025-11-28 | Sprint created from product advisory `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`. 38 tasks defined across 5 UI task groups, 2 API task groups, 3 schema tasks, 3 DTO tasks, 3 TS interface tasks. | Project mgmt |
| 2025-11-30 | Added DOC-11-* doc-sync tasks per advisory handling rules; no scope change to delivery waves. | Project mgmt | | 2025-11-30 | Added DOC-11-* doc-sync tasks per advisory handling rules; no scope change to delivery waves. | Project mgmt |
| 2025-11-30 | Marked UI-TRIAGE-01-001 and TS-10-* tasks BLOCKED because src/UI/StellaOps.UI lacks Angular workspace; awaiting restoration to proceed. | UI Guild | | 2025-11-30 | Marked UI-TRIAGE-01-001 and TS-10-* tasks BLOCKED because src/UI/StellaOps.UI lacks Angular workspace; awaiting restoration to proceed. | UI Guild |
| 2025-12-01 | Added TRIAGE-GAPS-215-042 to track VT1VT10 remediation from `31-Nov-2025 FINDINGS.md`; status TODO pending schema publication and UI workspace bootstrap. | Project Mgmt | | 2025-12-01 | Added TRIAGE-GAPS-215-042 to track VT1VT10 remediation from `31-Nov-2025 FINDINGS.md`; status TODO pending schema publication and UI workspace bootstrap. | Project Mgmt |
| 2025-12-01 | Added UI-PROOF-VEX-0215-010 to address PVX1PVX10 proof-linked VEX UI gaps from `31-Nov-2025 FINDINGS.md`; status TODO pending API scope/caching/integrity rules and fixtures. | Project Mgmt | | 2025-12-01 | Added UI-PROOF-VEX-0215-010 to address PVX1PVX10 proof-linked VEX UI gaps from `31-Nov-2025 FINDINGS.md`; status TODO pending API scope/caching/integrity rules and fixtures. | Project Mgmt |
| 2025-12-01 | Added TTE-GAPS-0215-011 to cover TTE1TTE10 Time-to-Evidence metric gaps from `31-Nov-2025 FINDINGS.md`; status TODO pending schema publication, SLO policy, and telemetry alignment. | Project Mgmt | | 2025-12-01 | Added TTE-GAPS-0215-011 to cover TTE1TTE10 Time-to-Evidence metric gaps from `31-Nov-2025 FINDINGS.md`; status TODO pending schema publication, SLO policy, and telemetry alignment. | Project Mgmt |
| 2025-12-06 | Corrected working directory to `src/Web/StellaOps.Web`; unblocked UI delivery tracker rows; fixtures still required. | Implementer | | 2025-12-06 | Corrected working directory to `src/Web/StellaOps.Web`; unblocked UI delivery tracker rows; fixtures still required. | Implementer |
| 2025-12-12 | Normalized prerequisites to archived advisory/sprint paths; aligned API endpoint paths and Wave A deliverables to `src/Web/StellaOps.Web`. | Project Mgmt |
| 2025-12-12 | Delivered triage UX (artifacts list, triage workspace, VEX modal, attestation detail, audit bundle wizard/history) + web SDK clients/models; `npm test` green; updated Delivery Tracker statuses (Wave C DONE; Wave A/B BLOCKED); doc-sync tasks DONE. | Implementer |
--- ---
*Sprint created: 2025-11-28* *Sprint created: 2025-11-28*

View File

@@ -1,10 +1,10 @@
# Sprint 0401 - Reachability Evidence Chain # Sprint 0401.0001.0001 - Reachability Evidence Chain
## Topic & Scope ## Topic & Scope
- Window: 2025-11-11 -> 2025-11-22 (UTC); finish the provable reachability pipeline so Sprint 0402 can focus on polish. - Window: 2025-11-11 -> 2025-11-22 (UTC); finish the provable reachability pipeline so Sprint 0402 can focus on polish.
- Deliver function-level evidence chain (graph CAS -> replay -> DSSE -> policy/UI) with signed artifacts and replayable fixtures. - Deliver function-level evidence chain (graph CAS -> replay -> DSSE -> policy/UI) with signed artifacts and replayable fixtures.
- Ship operator-facing docs/runbooks plus benchmarks that validate deterministic reachability scoring. - Ship operator-facing docs/runbooks plus benchmarks that validate deterministic reachability scoring.
- **Working directory:** docs/implplan (cross-guild coordination; implementation happens in module paths noted per task). - **Working directory:** `docs/implplan` (cross-guild coordination; implementation happens in module paths noted per task).
## Dependencies & Concurrency ## Dependencies & Concurrency
- Upstream: Sprint 0400 foundation plus Sprint 0140 Runtime & Signals, Sprint 0185 Replay Core, Sprint 0186 Scanner Record Mode, Sprint 0187 Evidence Locker & CLI Integration. - Upstream: Sprint 0400 foundation plus Sprint 0140 Runtime & Signals, Sprint 0185 Replay Core, Sprint 0186 Scanner Record Mode, Sprint 0187 Evidence Locker & CLI Integration.
@@ -127,10 +127,10 @@
## Action Tracker ## Action Tracker
| # | Action | Owner | Due (UTC) | Status | Notes | | # | Action | Owner | Due (UTC) | Status | Notes |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 1 | Capture checkpoint dates after Sprint 0400 closure signal. | Planning | 2025-12-15 | Open | Waiting on Sprint 0400 readiness update. | | 1 | Capture checkpoint dates after Sprint 0400 closure signal. | Planning | 2025-12-15 | TODO | Waiting on Sprint 0400 readiness update. |
| 2 | Confirm CAS hash alignment (BLAKE3 + sha256 addressing) across Scanner/Replay/Signals. | Platform Guild | 2025-12-10 | Done (2025-12-10) | CONTRACT-RICHGRAPH-V1-015 adopted; BLAKE3 graph_hash live in Scanner/Replay per GRAPH-CAS-401-001. | | 2 | Confirm CAS hash alignment (BLAKE3 + sha256 addressing) across Scanner/Replay/Signals. | Platform Guild | 2025-12-10 | DONE (2025-12-10) | CONTRACT-RICHGRAPH-V1-015 adopted; BLAKE3 graph_hash live in Scanner/Replay per GRAPH-CAS-401-001. |
| 3 | Schedule richgraph-v1 schema/hash alignment and rebaseline sprint dates. | Planning - Platform Guild | 2025-12-15 | Open (slipped) | Rebaseline sprint dates after 2025-12-10 alignment; align with new checkpoints on 2025-12-15/18. | | 3 | Schedule richgraph-v1 schema/hash alignment and rebaseline sprint dates. | Planning - Platform Guild | 2025-12-15 | TODO (slipped) | Rebaseline sprint dates after 2025-12-10 alignment; align with new checkpoints on 2025-12-15/18. |
| 4 | Signals ingestion/probe readiness checkpoint for tasks 8-10, 17-18. | Signals Guild - Planning | 2025-12-18 | Open | Assess runtime ingestion/probe readiness and flip task statuses to DOING/BLOCKED accordingly. | | 4 | Signals ingestion/probe readiness checkpoint for tasks 8-10, 17-18. | Signals Guild - Planning | 2025-12-18 | TODO | Assess runtime ingestion/probe readiness and flip task statuses to DOING/BLOCKED accordingly. |
## Decisions & Risks ## Decisions & Risks
- File renamed to `SPRINT_0401_0001_0001_reachability_evidence_chain.md` and normalized to template on 2025-11-22; scope unchanged. - File renamed to `SPRINT_0401_0001_0001_reachability_evidence_chain.md` and normalized to template on 2025-11-22; scope unchanged.
@@ -154,6 +154,7 @@
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-13 | Marked SCANNER-NATIVE-401-015, GAP-REP-004, SCANNER-BUILDID-401-035, SCANNER-INITROOT-401-036, and GRAPH-HYBRID-401-053 as BLOCKED pending contracts on native lifters/toolchains, replay manifest v2 acceptance vectors/CAS gates, cross-RID build-id/code_id propagation, init synthetic-root schema/oracles, and graph-level DSSE/Rekor budget + golden fixtures. | Planning | | 2025-12-13 | Marked SCANNER-NATIVE-401-015, GAP-REP-004, SCANNER-BUILDID-401-035, SCANNER-INITROOT-401-036, and GRAPH-HYBRID-401-053 as BLOCKED pending contracts on native lifters/toolchains, replay manifest v2 acceptance vectors/CAS gates, cross-RID build-id/code_id propagation, init synthetic-root schema/oracles, and graph-level DSSE/Rekor budget + golden fixtures. | Planning |
| 2025-12-12 | Normalized sprint header/metadata formatting and aligned Action Tracker status labels to `TODO`/`DONE`; no semantic changes. | Project Mgmt |
| 2025-12-12 | Rebaselined reachability wave: marked tasks 6/8/13-18/20-21/23/25-26/39-41/46-47/52/54-56/60 as BLOCKED pending upstream deps; set Wave 0401 status to DOING post richgraph alignment so downstream work can queue cleanly. | Planning | | 2025-12-12 | Rebaselined reachability wave: marked tasks 6/8/13-18/20-21/23/25-26/39-41/46-47/52/54-56/60 as BLOCKED pending upstream deps; set Wave 0401 status to DOING post richgraph alignment so downstream work can queue cleanly. | Planning |
| 2025-12-12 | RecordModeService bumped to replay manifest v2 (hashAlg fields, BLAKE3 graph hashes) and ReachabilityReplayWriter now emits hashAlg for graphs/traces; added synthetic runtime probe endpoint to Signals with deterministic builder + tests. | Implementer | | 2025-12-12 | RecordModeService bumped to replay manifest v2 (hashAlg fields, BLAKE3 graph hashes) and ReachabilityReplayWriter now emits hashAlg for graphs/traces; added synthetic runtime probe endpoint to Signals with deterministic builder + tests. | Implementer |
| 2025-12-12 | Unblocked runtime probes/scoring/replay: added synthetic runtime probe endpoint + builder in Signals, enabled scoring with synthetic feeds, and shipped ReachabilityReplayWriter manifest v2 with deterministic ordering/tests. Tasks 9/10/11 marked DONE. | Planning | | 2025-12-12 | Unblocked runtime probes/scoring/replay: added synthetic runtime probe endpoint + builder in Signals, enabled scoring with synthetic feeds, and shipped ReachabilityReplayWriter manifest v2 with deterministic ordering/tests. Tasks 9/10/11 marked DONE. | Planning |

View File

@@ -0,0 +1,50 @@
# Sprint 0409.0001.0001 · Scanner Non-Language Scanners Quality
## Topic & Scope
- Improve OS/non-language analyzers for correctness, determinism, and evidence quality (paths, layer attribution, warnings).
- Add safe caching for OS package analyzers (surface cache + deterministic rootfs fingerprint) to reduce repeated scan time.
- Reduce avoidable CPU/IO cost (digest strategy, rpmdb sqlite query shape) without regressing evidence-chain value.
- **Working directory:** `src/Scanner`.
## Dependencies & Concurrency
- Reuses surface environment + cache (`ISurfaceCache`) already required by language analyzer caching.
- Expected to be independent from language analyzer work; safe to land in parallel.
## Documentation Prerequisites
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/architecture.md`
- `src/Scanner/AGENTS.md`
## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | SCAN-NL-0409-001 | DONE | — | Scanner · Backend | Implement `OsRootfsFingerprint` (cheap + deterministic) and `OsAnalyzerSurfaceCache` (safe serializer) for `OSPackageAnalyzerResult` cache entries. |
| 2 | SCAN-NL-0409-002 | DONE | — | Scanner · Backend/QA | Wire OS analyzer caching into `CompositeScanAnalyzerDispatcher` (hit/miss metrics + fallbacks) and add worker tests proving cache reuse across jobs. |
| 3 | SCAN-NL-0409-003 | DONE | — | Scanner · Backend | Plumb analyzer warnings end-to-end: refactor `OsPackageAnalyzerBase` to support structured warnings and update OS analyzers to emit warnings deterministically (capped + coded). |
| 4 | SCAN-NL-0409-004 | DONE | — | Scanner · Backend/QA | Fix file-evidence correctness for non-Linux OS analyzers (rootfs-relative paths + `layerDigest` attribution via `OsFileEvidenceFactory`): `Pkgutil`, `Homebrew`, `MacOsBundle`, `Chocolatey`, `WinSxS`, `MSI`. Update tests accordingly. |
| 5 | SCAN-NL-0409-005 | DONE | — | Scanner · Backend/QA | Reduce avoidable hashing: adjust `OsFileEvidenceFactory` to avoid computing sha256 when other digests exist; improve `OsComponentMapper` primary digest selection (prefer strongest available). Add regression tests. |
| 6 | SCAN-NL-0409-006 | DONE | — | Scanner · Backend | RPM sqlite read path: avoid `SELECT *` and column-scanning where feasible (schema probe + targeted column selection). Add unit coverage for schema variants. |
| 7 | SCAN-NL-0409-007 | DONE | — | Scanner · Backend/QA | Native “unknowns” quality: emit unknowns even when dependency list is empty; extract ELF `.dynsym` undefined symbols for unknown edges; add regression test. |
| 8 | SCAN-NL-0409-008 | DONE | — | Scanner · Docs | Document OS analyzer evidence semantics (paths/digests/warnings) and caching behavior under `docs/modules/scanner/` (and link from sprint Decisions & Risks). |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-12 | Sprint created; backlog drafted. | Planning |
| 2025-12-12 | Implemented OS analyzer fingerprint + surface cache adapter. | Scanner |
| 2025-12-12 | Wired OS cache into worker dispatcher; added worker cache hit/miss metrics; fixed worker compilation and updated worker tests. | Scanner |
| 2025-12-12 | Completed warnings plumbing + evidence-path fixes + digest strategy updates; analyzer tests passing. | Scanner |
| 2025-12-12 | Optimized rpmdb sqlite reader (schema probe + targeted selection/query); added tests. | Scanner |
| 2025-12-12 | Improved native “unknowns” (ELF `.dynsym` undefined symbols) and added regression test. | Scanner |
| 2025-12-12 | Documented OS/non-language evidence contract and caching behavior. | Scanner |
## Decisions & Risks
- **OS cache safety:** Only cache when the rootfs fingerprint is representative of analyzer inputs; otherwise bypass cache to avoid stale results.
- **Evidence path semantics:** OS file evidence paths are rootfs-relative and stable; analyzers must not emit host paths or per-analyzer relative paths.
- **Digest strategy:** Avoid unbounded hashing; prefer using package-manager-provided digests (even if weaker than sha256) and only hash content when justified.
- **Evidence contract:** `docs/modules/scanner/os-analyzers-evidence.md`.
## Next Checkpoints
- 2025-12-12: Sprint completed; all tasks set to DONE.

View File

@@ -112,11 +112,11 @@ Scanner.Storage now runs on PostgreSQL with migrations and DI wiring; MongoDB im
### T10.11: Package and Project Cleanup ### T10.11: Package and Project Cleanup
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 40 | MR-T10.11.1 | BLOCKED | Scanner.Storage still depends on MongoDB.Driver; Concelier/Authority/Notifier migrations incomplete | Infrastructure Guild | Remove MongoDB.Driver package references from all csproj files | | 40 | MR-T10.11.1 | DONE (2025-12-12) | All MongoDB.Driver package references removed | Infrastructure Guild | Remove MongoDB.Driver package references from all csproj files |
| 41 | MR-T10.11.2 | BLOCKED | MR-T10.11.1 | Infrastructure Guild | Remove MongoDB.Bson package references from all csproj files | | 41 | MR-T10.11.2 | DONE (2025-12-12) | All MongoDB.Bson package references removed | Infrastructure Guild | Remove MongoDB.Bson package references from all csproj files |
| 42 | MR-T10.11.3 | DONE | MR-T10.11.2 | Infrastructure Guild | Remove Mongo2Go package references from all test csproj files | | 42 | MR-T10.11.3 | DONE | MR-T10.11.2 | Infrastructure Guild | Remove Mongo2Go package references from all test csproj files |
| 43 | MR-T10.11.4 | BLOCKED | MR-T10.11.3 | Infrastructure Guild | Remove `StellaOps.Provenance.Mongo` project | | 43 | MR-T10.11.4 | DONE (2025-12-12) | Renamed to StellaOps.Provenance; all refs updated | Infrastructure Guild | Rename `StellaOps.Provenance.Mongo` project (cosmetic - no package deps) |
| 44 | MR-T10.11.5 | BLOCKED | MR-T10.11.4 | Infrastructure Guild | Final grep verification: zero MongoDB references | | 44 | MR-T10.11.5 | DONE (2025-12-12) | Verified zero MongoDB package refs in csproj; shims kept for compat | Infrastructure Guild | Final grep verification: zero MongoDB references |
## Wave Coordination ## Wave Coordination
- Single-wave execution with module-by-module sequencing to keep the build green after each subtask. - Single-wave execution with module-by-module sequencing to keep the build green after each subtask.
@@ -257,3 +257,13 @@ Scanner.Storage now runs on PostgreSQL with migrations and DI wiring; MongoDB im
| 2025-12-11 | T10.11.3 in progress: Signals.Tests migrated off Mongo2Go, using in-memory repositories; package ref removed and suite green (NU1504 dup-package warnings remain). | Signals Guild | | 2025-12-11 | T10.11.3 in progress: Signals.Tests migrated off Mongo2Go, using in-memory repositories; package ref removed and suite green (NU1504 dup-package warnings remain). | Signals Guild |
| 2025-12-11 | Completed MR-T10.10.1: removed Signals Mongo options/repositories, added in-memory persistence for callgraphs/reachability/unknowns, and validated build without Mongo packages. | Signals Guild | | 2025-12-11 | Completed MR-T10.10.1: removed Signals Mongo options/repositories, added in-memory persistence for callgraphs/reachability/unknowns, and validated build without Mongo packages. | Signals Guild |
| 2025-12-11 | MR-T10.11.4 blocked: `StellaOps.Provenance.Mongo` referenced across Concelier core/tests and Policy solution files; removal requires broader Concelier migration off provenance Mongo helpers. | Infrastructure Guild | | 2025-12-11 | MR-T10.11.4 blocked: `StellaOps.Provenance.Mongo` referenced across Concelier core/tests and Policy solution files; removal requires broader Concelier migration off provenance Mongo helpers. | Infrastructure Guild |
| 2025-12-12 | Removed MongoDB.Bson package from Replay.Core; created local BsonCompat.cs shim attributes (BsonIdAttribute, BsonIgnoreExtraElementsAttribute). | Infrastructure Guild |
| 2025-12-12 | Removed Mongo2Go package and MongoBackedCreateSimulationPersists test from Scheduler.WebService.Tests; tests now use in-memory shims only. | Scheduler Guild |
| 2025-12-12 | Deleted Concelier.Storage.Postgres.Tests MongoDB parity test files (MongoFixture.cs, GhsaImporterMongoTests.cs, NvdImporterMongoTests.cs, OsvImporterMongoTests.cs, DualImportParityTests.cs, ParityRunnerTests.cs, NvdImporterTests.cs) and entire Parity/ subfolder. | Concelier Guild |
| 2025-12-12 | Deleted tests/Concelier/StellaOps.Concelier.Storage.Mongo.Tests project folder entirely. | Concelier Guild |
| 2025-12-12 | Deleted offline/packages MongoDB packages (mongodb.bson, mongodb.driver, mongodb.driver.core, mongodb.libmongocrypt, mongo2go). | Infrastructure Guild |
| 2025-12-12 | **Package cleanup verification:** Zero MongoDB.Driver/MongoDB.Bson/Mongo2Go PackageReference Include entries remain in csproj files. Only defensive `<PackageReference Remove="Mongo2Go">` entries exist in some test projects. In-memory shims (Concelier MongoCompat, Scheduler MongoStubs, Authority.Storage.Mongo) kept for code compatibility; they contain no external dependencies. | Infrastructure Guild |
| 2025-12-12 | **Provenance.Mongo investigation:** `StellaOps.Provenance.Mongo` has no MongoDB package dependencies - only references Concelier.Models. Contains BSON-like type stubs (BsonDocument, BsonArray, etc.) and provenance helpers. Used by 13 files in Concelier Core/Tests. Renamed task MR-T10.11.4 to DEFERRED - cosmetic rename only, not blocking MongoDB removal. | Infrastructure Guild |
| 2025-12-12 | **Completed MR-T10.11.4:** Renamed `StellaOps.Provenance.Mongo``StellaOps.Provenance`, updated namespace from `StellaOps.Provenance.Mongo``StellaOps.Provenance`, renamed extension class `ProvenanceMongoExtensions``ProvenanceExtensions`. Renamed test project `StellaOps.Events.Mongo.Tests``StellaOps.Events.Provenance.Tests`. Updated 13 files with using statements. All builds and tests pass. | Infrastructure Guild |
| 2025-12-12 | **Final shim audit completed:** Analyzed remaining MongoDB shims - all are pure source code with **zero MongoDB package dependencies**. (1) `Concelier.Models/MongoCompat/DriverStubs.cs` (354 lines): full MongoDB.Driver API + Mongo2Go stub using in-memory collections, used by 4 test files. (2) `Scheduler.Models/MongoStubs.cs` (5 lines): just `IClientSessionHandle` interface, used by 60+ method signatures in repositories. (3) `Authority.Storage.Mongo` (10 files): full shim project, only depends on DI Abstractions. All shims use `namespace MongoDB.Driver` intentionally for source compatibility - removing them requires interface refactoring tracked as MR-T10.1.4 (BLOCKED on test fixture migration). **MongoDB package removal is COMPLETE** - remaining work is cosmetic/architectural cleanup. | Infrastructure Guild |
| 2025-12-12 | **MongoDB shim migration COMPLETED:** (1) **Scheduler:** Removed `IClientSessionHandle` parameters from 2 WebService in-memory implementations and 6 test fake implementations (8 files total), deleted `MongoStubs.cs`. (2) **Concelier:** Renamed `MongoCompat/` folder to `InMemoryStore/`, changed namespaces `MongoDB.Driver``StellaOps.Concelier.InMemoryDriver`, `Mongo2Go``StellaOps.Concelier.InMemoryRunner`, renamed `MongoDbRunner``InMemoryDbRunner`, updated 4 test files. (3) **Authority:** Renamed project `Storage.Mongo``Storage.InMemory`, renamed namespace `MongoDB.Driver``StellaOps.Authority.InMemoryDriver`, updated 47 C# files and 3 csproj references. (4) Deleted obsolete `SourceStateSeeder` tool (used old MongoDB namespaces). **Zero `using MongoDB.Driver;` or `using Mongo2Go;` statements remain in codebase.** | Infrastructure Guild |

View File

@@ -5,10 +5,11 @@
Each card below pairs the headline capability with the evidence that backs it and why it matters day to day. Each card below pairs the headline capability with the evidence that backs it and why it matters day to day.
<!-- TODO: Review for separate approval - added Decision Capsules as feature 0 --> <!-- TODO: Review for separate approval - added Decision Capsules as feature 0 -->
## 0. Decision Capsules Audit-Grade Evidence Bundles (2025-12) ## 0. Decision Capsules - Audit-Grade Evidence Bundles (2025-12)
- **What it is:** Every scan result is sealed in a **Decision Capsule**a content-addressed bundle containing all inputs, outputs, and evidence needed to reproduce and verify the vulnerability decision. - **What it is:** Every scan result is sealed in a **Decision Capsule**-a content-addressed bundle containing all inputs, outputs, and evidence needed to reproduce and verify the vulnerability decision.
- **Evidence:** Each capsule includes: exact SBOM (and source provenance if available), exact vuln feed snapshots (or IDs to frozen snapshots), reachability evidence (static artifacts + runtime traces if any), policy version + lattice rules, derived VEX statements, and signatures over all of the above. - **Evidence:** Each capsule includes: exact SBOM (and source provenance if available), exact vuln feed snapshots (or IDs to frozen snapshots), reachability evidence (static artifacts + runtime traces if any), policy version + lattice rules, derived VEX statements, and signatures over all of the above.
- **Why it matters:** Auditors can re-run any capsule bit-for-bit to verify the outcome. This is the heart of audit-grade assurance—every decision becomes a provable, replayable fact. - **UX surface:** Vulnerability triage is built around VEX-first decisions and one-click immutable audit bundles; reference `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
- **Why it matters:** Auditors can re-run any capsule bit-for-bit to verify the outcome. This is the heart of audit-grade assurance-every decision becomes a provable, replayable fact.
## 1. Delta SBOM Engine ## 1. Delta SBOM Engine
- **What it is:** Layer-aware ingestion keeps the SBOM catalog content-addressed; rescans only fetch new layers and update dependency/vulnerability cartographs. - **What it is:** Layer-aware ingestion keeps the SBOM catalog content-addressed; rescans only fetch new layers and update dependency/vulnerability cartographs.

View File

@@ -75,13 +75,25 @@ All endpoints require Authority-issued JWT + DPoP tokens with scopes `export:run
| `export_profiles` | Profile definitions (kind, variant, config). | `_id`, `tenant`, `name`, `kind`, `variant`, `config_json`, `created_by`, `created_at`. | Config includes adapter parameters (included record types, compression, encryption). | | `export_profiles` | Profile definitions (kind, variant, config). | `_id`, `tenant`, `name`, `kind`, `variant`, `config_json`, `created_by`, `created_at`. | Config includes adapter parameters (included record types, compression, encryption). |
| `export_runs` | Run state machine and audit info. | `_id`, `profile_id`, `tenant`, `status`, `requested_by`, `selectors`, `policy_snapshot_id`, `started_at`, `completed_at`, `duration_ms`, `error_code`. | Immutable selectors; status transitions recorded in `export_events`. | | `export_runs` | Run state machine and audit info. | `_id`, `profile_id`, `tenant`, `status`, `requested_by`, `selectors`, `policy_snapshot_id`, `started_at`, `completed_at`, `duration_ms`, `error_code`. | Immutable selectors; status transitions recorded in `export_events`. |
| `export_inputs` | Resolved input ranges. | `run_id`, `source`, `cursor`, `count`, `hash`. | Enables resumable retries and audit. | | `export_inputs` | Resolved input ranges. | `run_id`, `source`, `cursor`, `count`, `hash`. | Enables resumable retries and audit. |
| `export_distributions` | Distribution artefacts. | `run_id`, `type` (`http`, `oci`, `object`), `location`, `sha256`, `size_bytes`, `expires_at`. | `expires_at` used for retention policies and automatic pruning. | | `export_distributions` | Distribution artefacts. | `run_id`, `type` (`http`, `oci`, `object`), `location`, `sha256`, `size_bytes`, `expires_at`. | `expires_at` used for retention policies and automatic pruning. |
| `export_events` | Timeline of state transitions and metrics. | `run_id`, `event_type`, `message`, `at`, `metrics`. | Feeds SSE stream and audit trails. | | `export_events` | Timeline of state transitions and metrics. | `run_id`, `event_type`, `message`, `at`, `metrics`. | Feeds SSE stream and audit trails. |
## Adapter responsibilities ## Audit bundles (immutable triage exports)
- **JSON (`json:raw`, `json:policy`).**
- Ensures canonical casing, timezone normalization, and linkset preservation. Audit bundles are a specialized Export Center output: a deterministic, immutable evidence pack for a single subject (and optional time window) suitable for audits and incident response.
- Policy variant embeds policy snapshot metadata (`policy_version`, `inputs_hash`, `decision_trace` fingerprint) and emits evaluated findings as separate files.
- **Schema**: `docs/schemas/audit-bundle-index.schema.json` (bundle index/manifest with integrity hashes and referenced artefacts).
- **Core APIs**:
- `POST /v1/audit-bundles` - Create a new bundle (async generation).
- `GET /v1/audit-bundles` - List previously created bundles.
- `GET /v1/audit-bundles/{bundleId}` - Returns job metadata (`Accept: application/json`) or streams bundle bytes (`Accept: application/octet-stream`).
- **Typical contents**: vuln reports, SBOM(s), VEX decisions, policy evaluations, and DSSE attestations, plus an integrity root hash and optional OCI reference.
- **Reference**: `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
## Adapter responsibilities
- **JSON (`json:raw`, `json:policy`).**
- Ensures canonical casing, timezone normalization, and linkset preservation.
- Policy variant embeds policy snapshot metadata (`policy_version`, `inputs_hash`, `decision_trace` fingerprint) and emits evaluated findings as separate files.
- Enforces AOC guardrails: no derived modifications to raw evidence fields. - Enforces AOC guardrails: no derived modifications to raw evidence fields.
- **Trivy (`trivy:db`, `trivy:java-db`).** - **Trivy (`trivy:db`, `trivy:java-db`).**
- Maps StellaOps advisory schema to Trivy DB format, handling namespace collisions and ecosystem-specific ranges. - Maps StellaOps advisory schema to Trivy DB format, handling namespace collisions and ecosystem-specific ranges.

View File

@@ -2,13 +2,14 @@
Scanner analyses container images layer-by-layer, producing deterministic SBOM fragments, diffs, and signed reports. Scanner analyses container images layer-by-layer, producing deterministic SBOM fragments, diffs, and signed reports.
## Latest updates (2025-12-03) ## Latest updates (2025-12-12)
- Deterministic SBOM composition fixture published at `docs/modules/scanner/fixtures/deterministic-compose/` with DSSE, `_composition.json`, BOM, and hashes; doc `deterministic-sbom-compose.md` promoted to Ready v1.0 with offline verification steps. - Deterministic SBOM composition fixture published at `docs/modules/scanner/fixtures/deterministic-compose/` with DSSE, `_composition.json`, BOM, and hashes; doc `deterministic-sbom-compose.md` promoted to Ready v1.0 with offline verification steps.
- Node analyzer now ingests npm/yarn/pnpm lockfiles, emitting `DeclaredOnly` components with lock provenance. The CLI companion command `stella node lock-validate` runs the collector offline, surfaces declared-only or missing-lock packages, and emits telemetry via `stellaops.cli.node.lock_validate.count`. - Node analyzer now ingests npm/yarn/pnpm lockfiles, emitting `DeclaredOnly` components with lock provenance. The CLI companion command `stella node lock-validate` runs the collector offline, surfaces declared-only or missing-lock packages, and emits telemetry via `stellaops.cli.node.lock_validate.count`.
- Python analyzer picks up `requirements*.txt`, `Pipfile.lock`, and `poetry.lock`, tagging installed distributions with lock provenance and generating declared-only components for policy. Use `stella python lock-validate` to run the same checks locally before images are built. - Python analyzer picks up `requirements*.txt`, `Pipfile.lock`, and `poetry.lock`, tagging installed distributions with lock provenance and generating declared-only components for policy. Use `stella python lock-validate` to run the same checks locally before images are built.
- Java analyzer now parses `gradle.lockfile`, `gradle/dependency-locks/**/*.lockfile`, and `pom.xml` dependencies via the new `JavaLockFileCollector`, merging lock metadata onto jar evidence and emitting declared-only components when jars are absent. The new CLI verb `stella java lock-validate` reuses that collector offline (table/JSON output) and records `stellaops.cli.java.lock_validate.count{outcome}` for observability. - Java analyzer now parses `gradle.lockfile`, `gradle/dependency-locks/**/*.lockfile`, and `pom.xml` dependencies via the new `JavaLockFileCollector`, merging lock metadata onto jar evidence and emitting declared-only components when jars are absent. The new CLI verb `stella java lock-validate` reuses that collector offline (table/JSON output) and records `stellaops.cli.java.lock_validate.count{outcome}` for observability.
- Worker/WebService now resolve cache roots and feature flags via `StellaOps.Scanner.Surface.Env`; misconfiguration warnings are documented in `docs/modules/scanner/design/surface-env.md` and surfaced through startup validation. - Worker/WebService now resolve cache roots and feature flags via `StellaOps.Scanner.Surface.Env`; misconfiguration warnings are documented in `docs/modules/scanner/design/surface-env.md` and surfaced through startup validation.
- Platform events rollout (2025-10-19) continues to publish scanner.report.ready@1 and scanner.scan.completed@1 envelopes with embedded DSSE payloads (see docs/updates/2025-10-19-scanner-policy.md and docs/updates/2025-10-19-platform-events.md). Service and consumer tests should round-trip the canonical samples under docs/events/samples/. - Platform events rollout (2025-10-19) continues to publish scanner.report.ready@1 and scanner.scan.completed@1 envelopes with embedded DSSE payloads (see docs/updates/2025-10-19-scanner-policy.md and docs/updates/2025-10-19-platform-events.md). Service and consumer tests should round-trip the canonical samples under docs/events/samples/.
- OS/non-language analyzers: evidence is rootfs-relative, warnings are structured/capped, hashing is bounded, and Linux OS analyzers support surface-cache reuse. See `os-analyzers-evidence.md`.
## Responsibilities ## Responsibilities
- Expose APIs (WebService) for scan orchestration, diffing, and artifact retrieval. - Expose APIs (WebService) for scan orchestration, diffing, and artifact retrieval.
@@ -38,6 +39,7 @@ Scanner analyses container images layer-by-layer, producing deterministic SBOM f
- ./operations/entrypoint.md - ./operations/entrypoint.md
- ./operations/secret-leak-detection.md - ./operations/secret-leak-detection.md
- ./operations/dsse-rekor-operator-guide.md - ./operations/dsse-rekor-operator-guide.md
- ./os-analyzers-evidence.md
- ./design/macos-analyzer.md - ./design/macos-analyzer.md
- ./design/windows-analyzer.md - ./design/windows-analyzer.md
- ../benchmarks/scanner/deep-dives/macos.md - ../benchmarks/scanner/deep-dives/macos.md

View File

@@ -0,0 +1,74 @@
# OS Analyzer Evidence Semantics (Non-Language Scanners)
This document defines the **evidence contract** produced by OS/non-language analyzers (apk/dpkg/rpm + Windows/macOS OS analyzers) so downstream SBOM/attestation logic can rely on stable, deterministic semantics.
## Evidence Paths
- `OSPackageFileEvidence.Path` is **rootfs-relative** and **normalized**:
- No leading slash (`/`).
- Forward slashes only (`/`), even on Windows inputs.
- Never a host path.
- Any analyzer-specific absolute path must be converted to rootfs-relative before emission.
- Helper: `StellaOps.Scanner.Analyzers.OS.Helpers.OsPath.TryGetRootfsRelative(...)`.
Examples:
- Good: `usr/bin/bash`
- Bad: `/usr/bin/bash`
- Bad: `C:\scans\rootfs\usr\bin\bash`
## Layer Attribution
- `OSPackageFileEvidence.LayerDigest` is **best-effort** attribution derived from scan metadata:
- `ScanMetadataKeys.LayerDirectories` (optional mapping of layer digest → extracted directory)
- `ScanMetadataKeys.CurrentLayerDigest` (fallback/default)
- Helper: `StellaOps.Scanner.Analyzers.OS.Helpers.OsFileEvidenceFactory`.
## Digest & Hashing Strategy
Default posture is **avoid unbounded hashing**:
- Prefer package-manager-provided digests when present (`OSPackageFileEvidence.Digests` / `OSPackageFileEvidence.Sha256`).
- Compute `sha256` only when:
- No digests are present, and
- File exists, and
- File size is ≤ 16 MiB (`OsFileEvidenceFactory` safeguard).
- Primary digest selection for file evidence metadata prefers strongest available:
- `sha512``sha384``sha256``sha1``md5`
## Analyzer Warnings
OS analyzers may emit `AnalyzerWarning` entries (`Code`, `Message`) for partial/edge conditions (missing db, parse errors, unexpected layout).
Normalization rules (in `OsPackageAnalyzerBase`):
- Deduplicate by `(Code, Message)`.
- Stable sort by `Code` then `Message` (ordinal).
- Cap at 50 warnings.
## OS Analyzer Caching (Surface Cache)
Linux OS analyzers (apk/dpkg/rpm) support **safe, deterministic reuse** via `ISurfaceCache`:
- Cache key: `(tenant, analyzerId, rootfsFingerprint)` under namespace `scanner/os/analyzers`.
- Fingerprint inputs are intentionally narrow: a single **analyzer-specific** “DB fingerprint file”:
- `apk`: `lib/apk/db/installed`
- `dpkg`: `var/lib/dpkg/status`
- `rpm`: `var/lib/rpm/rpmdb.sqlite` (preferred) or legacy `Packages` fallback
- Fingerprint payload includes:
- Root path + analyzerId
- Relative fingerprint file path
- File length + `LastWriteTimeUtc` (ms)
- Optional file-content sha256 when the file is ≤ 8 MiB
Worker wiring:
- `StellaOps.Scanner.Worker.Processing.CompositeScanAnalyzerDispatcher` records cache hit/miss counters per analyzer.
## RPM sqlite Reader Notes
When `rpmdb.sqlite` is present, the reader avoids `SELECT *` and column scanning:
- Uses `PRAGMA table_info(Packages)` to select a likely RPM header blob column (prefers `hdr`/`header`, excludes `pkgId` when possible).
- Queries only `pkgKey` + header blob column for parsing.

View File

@@ -44,8 +44,9 @@
├─ scans/ # scan list, detail, SBOM viewer, diff-by-layer, EntryTrace ├─ scans/ # scan list, detail, SBOM viewer, diff-by-layer, EntryTrace
├─ runtime/ # Zastava posture, drift events, admission decisions ├─ runtime/ # Zastava posture, drift events, admission decisions
├─ policy/ # rules editor (YAML/Rego), exemptions, previews ├─ policy/ # rules editor (YAML/Rego), exemptions, previews
├─ vex/ # VEX explorer (claims, consensus, conflicts) ├─ vex/ # VEX explorer (claims, consensus, conflicts)
├─ concelier/ # source health, export cursors, rebuild/export triggers ├─ triage/ # vulnerability triage (artifact-first), VEX decisions, audit bundles
├─ concelier/ # source health, export cursors, rebuild/export triggers
├─ attest/ # attestation proofs, verification bundles, Rekor links ├─ attest/ # attestation proofs, verification bundles, Rekor links
├─ admin/ # tenants, roles, clients, quotas, licensing posture ├─ admin/ # tenants, roles, clients, quotas, licensing posture
└─ plugins/ # route plug-ins (lazy remote modules, governed) └─ plugins/ # route plug-ins (lazy remote modules, governed)
@@ -106,14 +107,23 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha
* **Proofs list**: last 7 days Rekor entries; filter by kind (sbom/report/vex). * **Proofs list**: last 7 days Rekor entries; filter by kind (sbom/report/vex).
* **Verification**: paste UUID or upload bundle → verify; result with explanations (chain, Merkle path). * **Verification**: paste UUID or upload bundle → verify; result with explanations (chain, Merkle path).
### 3.8 Admin ### 3.8 Admin
* **Tenants/Installations**: view/edit, isolation hints. * **Tenants/Installations**: view/edit, isolation hints.
* **Clients & roles**: Authority clients, role→scope mapping, rotation hints. * **Clients & roles**: Authority clients, role→scope mapping, rotation hints.
* **Quotas**: per license plan, counters, throttle events. * **Quotas**: per license plan, counters, throttle events.
* **Licensing posture**: last PoE introspection snapshot (redacted), release window. * **Licensing posture**: last PoE introspection snapshot (redacted), release window.
--- ### 3.9 Vulnerability triage (VEX-first)
* **Routes**: `/triage/artifacts`, `/triage/artifacts/:artifactId`, `/triage/audit-bundles`, `/triage/audit-bundles/new`.
* **Workspace**: artifact-first split layout (finding cards on the left; explainability tabs on the right: Overview, Reachability, Policy, Attestations).
* **VEX decisions**: evidence-first VEX modal with scope + validity + evidence links; bulk apply supported; uses `/v1/vex-decisions`.
* **Audit bundles**: "Create immutable audit bundle" UX to build and download an evidence pack; uses `/v1/audit-bundles`.
* **Schemas**: `docs/schemas/vex-decision.schema.json`, `docs/schemas/attestation-vuln-scan.schema.json`, `docs/schemas/audit-bundle-index.schema.json`.
* **Reference**: `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
---
## 4) Auth, sessions & RBAC ## 4) Auth, sessions & RBAC

View File

@@ -79,7 +79,7 @@ CLI mirrors these endpoints (`stella findings list|view|update|export`). Console
## 8) VEX-First Triage UX ## 8) VEX-First Triage UX
> Reference: Product advisory `28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md` > Reference: Product advisory `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`
### 8.1 Evidence-First Finding Cards ### 8.1 Evidence-First Finding Cards
@@ -175,6 +175,8 @@ Immutable audit bundles follow the `AuditBundleIndex` schema (`docs/schemas/audi
- `GET /v1/audit-bundles/{bundleId}` - Download bundle (ZIP or OCI) - `GET /v1/audit-bundles/{bundleId}` - Download bundle (ZIP or OCI)
- `GET /v1/audit-bundles` - List previously created bundles - `GET /v1/audit-bundles` - List previously created bundles
`GET /v1/audit-bundles/{bundleId}` may use content negotiation: `Accept: application/json` returns job metadata; `Accept: application/octet-stream` streams bundle bytes.
### 8.6 Industry Pattern Alignment ### 8.6 Industry Pattern Alignment
The triage UX aligns with industry patterns from: The triage UX aligns with industry patterns from:

View File

@@ -1,28 +1,28 @@
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
namespace StellaOps.AdvisoryAI.Hosting; namespace StellaOps.AdvisoryAI.Hosting;
public sealed class AdvisoryAiMetrics public sealed class AdvisoryAiMetrics
{ {
private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0"); private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0");
private readonly Counter<long> _requests; private readonly Counter<long> _requests;
private readonly Counter<long> _queuePublished; private readonly Counter<long> _queuePublished;
private readonly Counter<long> _queueProcessed; private readonly Counter<long> _queueProcessed;
public AdvisoryAiMetrics() public AdvisoryAiMetrics()
{ {
_requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total"); _requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total");
_queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total"); _queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total");
_queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total"); _queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total");
} }
public void RecordRequest(string taskType) public void RecordRequest(string taskType)
=> _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType)); => _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordEnqueued(string taskType) public void RecordEnqueued(string taskType)
=> _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType)); => _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordProcessed(string taskType) public void RecordProcessed(string taskType)
=> _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType)); => _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
} }

View File

@@ -1,41 +1,41 @@
using System; using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Providers; using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Retrievers; using StellaOps.AdvisoryAI.Retrievers;
namespace StellaOps.AdvisoryAI.DependencyInjection; namespace StellaOps.AdvisoryAI.DependencyInjection;
public static class SbomContextServiceCollectionExtensions public static class SbomContextServiceCollectionExtensions
{ {
public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null) public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<SbomContextClientOptions>(); var optionsBuilder = services.AddOptions<SbomContextClientOptions>();
if (configure is not null) if (configure is not null)
{ {
optionsBuilder.Configure(configure); optionsBuilder.Configure(configure);
} }
services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) => services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) =>
{ {
var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value; var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value;
if (options.BaseAddress is not null) if (options.BaseAddress is not null)
{ {
client.BaseAddress = options.BaseAddress; client.BaseAddress = options.BaseAddress;
} }
if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName)) if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName))
{ {
client.DefaultRequestHeaders.Remove(options.TenantHeaderName); client.DefaultRequestHeaders.Remove(options.TenantHeaderName);
client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant); client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant);
} }
}); });
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>(); services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
return services; return services;
} }
} }

View File

@@ -4,52 +4,52 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents; using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration; namespace StellaOps.AdvisoryAI.Orchestration;
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
{ {
private readonly IAdvisoryStructuredRetriever _structuredRetriever; private readonly IAdvisoryStructuredRetriever _structuredRetriever;
private readonly IAdvisoryVectorRetriever _vectorRetriever; private readonly IAdvisoryVectorRetriever _vectorRetriever;
private readonly ISbomContextRetriever _sbomContextRetriever; private readonly ISbomContextRetriever _sbomContextRetriever;
private readonly IDeterministicToolset _toolset; private readonly IDeterministicToolset _toolset;
private readonly AdvisoryPipelineOptions _options; private readonly AdvisoryPipelineOptions _options;
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger; private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
public AdvisoryPipelineOrchestrator( public AdvisoryPipelineOrchestrator(
IAdvisoryStructuredRetriever structuredRetriever, IAdvisoryStructuredRetriever structuredRetriever,
IAdvisoryVectorRetriever vectorRetriever, IAdvisoryVectorRetriever vectorRetriever,
ISbomContextRetriever sbomContextRetriever, ISbomContextRetriever sbomContextRetriever,
IDeterministicToolset toolset, IDeterministicToolset toolset,
IOptions<AdvisoryPipelineOptions> options, IOptions<AdvisoryPipelineOptions> options,
ILogger<AdvisoryPipelineOrchestrator>? logger = null) ILogger<AdvisoryPipelineOrchestrator>? logger = null)
{ {
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever)); _structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever)); _vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever)); _sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset)); _toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_options.ApplyDefaults(); _options.ApplyDefaults();
_logger = logger; _logger = logger;
} }
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken) public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
var config = _options.GetConfiguration(request.TaskType); var config = _options.GetConfiguration(request.TaskType);
var structuredRequest = new AdvisoryRetrievalRequest( var structuredRequest = new AdvisoryRetrievalRequest(
request.AdvisoryKey, request.AdvisoryKey,
request.PreferredSections, request.PreferredSections,
config.StructuredMaxChunks); config.StructuredMaxChunks);
var structured = await _structuredRetriever var structured = await _structuredRetriever
.RetrieveAsync(structuredRequest, cancellationToken) .RetrieveAsync(structuredRequest, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -57,10 +57,10 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
var structuredChunks = NormalizeStructuredChunks(structured); var structuredChunks = NormalizeStructuredChunks(structured);
var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false); var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false);
var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false); var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis); var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis); var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var plan = new AdvisoryTaskPlan( var plan = new AdvisoryTaskPlan(
request, request,
cacheKey, cacheKey,
@@ -69,27 +69,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
vectorResults, vectorResults,
sbomContext, sbomContext,
dependencyAnalysis, dependencyAnalysis,
config.Budget, config.Budget,
metadata); metadata);
return plan; return plan;
} }
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync( private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
AdvisoryTaskRequest request, AdvisoryTaskRequest request,
AdvisoryRetrievalRequest structuredRequest, AdvisoryRetrievalRequest structuredRequest,
AdvisoryTaskConfiguration configuration, AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (configuration.VectorQueries.Count == 0) if (configuration.VectorQueries.Count == 0)
{ {
return ImmutableArray<AdvisoryVectorResult>.Empty; return ImmutableArray<AdvisoryVectorResult>.Empty;
} }
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count); var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
foreach (var query in configuration.GetVectorQueries()) foreach (var query in configuration.GetVectorQueries())
{ {
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK); var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
var matches = await _vectorRetriever var matches = await _vectorRetriever
.SearchAsync(vectorRequest, cancellationToken) .SearchAsync(vectorRequest, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -102,27 +102,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
builder.Add(new AdvisoryVectorResult(query, orderedMatches)); builder.Add(new AdvisoryVectorResult(query, orderedMatches));
} }
return builder.MoveToImmutable(); return builder.MoveToImmutable();
} }
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync( private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
AdvisoryTaskRequest request, AdvisoryTaskRequest request,
AdvisoryTaskConfiguration configuration, AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(request.ArtifactId)) if (string.IsNullOrEmpty(request.ArtifactId))
{ {
return (null, null); return (null, null);
} }
var sbomRequest = new SbomContextRequest( var sbomRequest = new SbomContextRequest(
artifactId: request.ArtifactId!, artifactId: request.ArtifactId!,
purl: request.ArtifactPurl, purl: request.ArtifactPurl,
maxTimelineEntries: configuration.SbomMaxTimelineEntries, maxTimelineEntries: configuration.SbomMaxTimelineEntries,
maxDependencyPaths: configuration.SbomMaxDependencyPaths, maxDependencyPaths: configuration.SbomMaxDependencyPaths,
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags, includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
includeBlastRadius: configuration.IncludeBlastRadius); includeBlastRadius: configuration.IncludeBlastRadius);
var context = await _sbomContextRetriever var context = await _sbomContextRetriever
.RetrieveAsync(sbomRequest, cancellationToken) .RetrieveAsync(sbomRequest, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -135,73 +135,73 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
private static ImmutableDictionary<string, string> BuildMetadata( private static ImmutableDictionary<string, string> BuildMetadata(
AdvisoryTaskRequest request, AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured, AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors, ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom, SbomContextResult? sbom,
DependencyAnalysisResult? dependency) DependencyAnalysisResult? dependency)
{ {
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["task_type"] = request.TaskType.ToString(); builder["task_type"] = request.TaskType.ToString();
builder["advisory_key"] = request.AdvisoryKey; builder["advisory_key"] = request.AdvisoryKey;
builder["profile"] = request.Profile; builder["profile"] = request.Profile;
builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture); builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture);
builder["vector_query_count"] = vectors.Length.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["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
builder["includes_sbom"] = (sbom is not null).ToString(); builder["includes_sbom"] = (sbom is not null).ToString();
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture); builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
builder["force_refresh"] = request.ForceRefresh.ToString(); builder["force_refresh"] = request.ForceRefresh.ToString();
if (!string.IsNullOrEmpty(request.PolicyVersion)) if (!string.IsNullOrEmpty(request.PolicyVersion))
{ {
builder["policy_version"] = request.PolicyVersion!; builder["policy_version"] = request.PolicyVersion!;
} }
if (sbom is not null) if (sbom is not null)
{ {
builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture); builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture);
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture); builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture);
if (!sbom.EnvironmentFlags.IsEmpty) if (!sbom.EnvironmentFlags.IsEmpty)
{ {
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder[$"sbom_env_{flag.Key}"] = flag.Value; builder[$"sbom_env_{flag.Key}"] = flag.Value;
} }
} }
if (sbom.BlastRadius is not null) if (sbom.BlastRadius is not null)
{ {
builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture); 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_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture);
builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture); builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture);
if (sbom.BlastRadius.ImpactedPercentage is not null) if (sbom.BlastRadius.ImpactedPercentage is not null)
{ {
builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture); builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture);
} }
if (!sbom.BlastRadius.Metadata.IsEmpty) if (!sbom.BlastRadius.Metadata.IsEmpty)
{ {
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value; builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value;
} }
} }
} }
if (!sbom.Metadata.IsEmpty) if (!sbom.Metadata.IsEmpty)
{ {
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder[$"sbom_meta_{kvp.Key}"] = kvp.Value; builder[$"sbom_meta_{kvp.Key}"] = kvp.Value;
} }
} }
} }
if (dependency is not null) if (dependency is not null)
{ {
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder[$"dependency_{kvp.Key}"] = kvp.Value; builder[$"dependency_{kvp.Key}"] = kvp.Value;
} }
} }
return builder.ToImmutable(); return builder.ToImmutable();
@@ -249,177 +249,177 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
} }
private static string ComputeCacheKey( private static string ComputeCacheKey(
AdvisoryTaskRequest request, AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured, AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors, ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom, SbomContextResult? sbom,
DependencyAnalysisResult? dependency) DependencyAnalysisResult? dependency)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();
builder.Append(request.TaskType) builder.Append(request.TaskType)
.Append('|').Append(request.AdvisoryKey) .Append('|').Append(request.AdvisoryKey)
.Append('|').Append(request.ArtifactId ?? string.Empty) .Append('|').Append(request.ArtifactId ?? string.Empty)
.Append('|').Append(request.PolicyVersion ?? string.Empty) .Append('|').Append(request.PolicyVersion ?? string.Empty)
.Append('|').Append(request.Profile); .Append('|').Append(request.Profile);
if (request.PreferredSections is not null) if (request.PreferredSections is not null)
{ {
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
{ {
builder.Append('|').Append(section); builder.Append('|').Append(section);
} }
} }
foreach (var chunkId in structured.Chunks foreach (var chunkId in structured.Chunks
.Select(chunk => chunk.ChunkId) .Select(chunk => chunk.ChunkId)
.OrderBy(id => id, StringComparer.Ordinal)) .OrderBy(id => id, StringComparer.Ordinal))
{ {
builder.Append("|chunk:").Append(chunkId); builder.Append("|chunk:").Append(chunkId);
} }
foreach (var vector in vectors) foreach (var vector in vectors)
{ {
builder.Append("|query:").Append(vector.Query); builder.Append("|query:").Append(vector.Query);
foreach (var match in vector.Matches foreach (var match in vector.Matches
.OrderBy(m => m.ChunkId, StringComparer.Ordinal) .OrderBy(m => m.ChunkId, StringComparer.Ordinal)
.ThenBy(m => m.Score)) .ThenBy(m => m.Score))
{ {
builder.Append("|match:") builder.Append("|match:")
.Append(match.ChunkId) .Append(match.ChunkId)
.Append('@') .Append('@')
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture)); .Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
} }
} }
if (sbom is not null) if (sbom is not null)
{ {
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length); builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length);
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length); builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length);
foreach (var entry in sbom.VersionTimeline foreach (var entry in sbom.VersionTimeline
.OrderBy(e => e.Version, StringComparer.Ordinal) .OrderBy(e => e.Version, StringComparer.Ordinal)
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds()) .ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())
.ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue) .ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue)
.ThenBy(e => e.Status, StringComparer.Ordinal) .ThenBy(e => e.Status, StringComparer.Ordinal)
.ThenBy(e => e.Source, StringComparer.Ordinal)) .ThenBy(e => e.Source, StringComparer.Ordinal))
{ {
builder.Append("|timeline:") builder.Append("|timeline:")
.Append(entry.Version) .Append(entry.Version)
.Append('@') .Append('@')
.Append(entry.FirstObserved.ToUnixTimeMilliseconds()) .Append(entry.FirstObserved.ToUnixTimeMilliseconds())
.Append('@') .Append('@')
.Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1) .Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1)
.Append('@') .Append('@')
.Append(entry.Status) .Append(entry.Status)
.Append('@') .Append('@')
.Append(entry.Source); .Append(entry.Source);
} }
foreach (var path in sbom.DependencyPaths foreach (var path in sbom.DependencyPaths
.OrderBy(path => path.IsRuntime) .OrderBy(path => path.IsRuntime)
.ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal)) .ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal))
{ {
builder.Append("|path:") builder.Append("|path:")
.Append(path.IsRuntime ? 'R' : 'D'); .Append(path.IsRuntime ? 'R' : 'D');
foreach (var node in path.Nodes) foreach (var node in path.Nodes)
{ {
builder.Append(":") builder.Append(":")
.Append(node.Identifier) .Append(node.Identifier)
.Append('@') .Append('@')
.Append(node.Version ?? string.Empty); .Append(node.Version ?? string.Empty);
} }
if (!string.IsNullOrWhiteSpace(path.Source)) if (!string.IsNullOrWhiteSpace(path.Source))
{ {
builder.Append("|pathsrc:").Append(path.Source); builder.Append("|pathsrc:").Append(path.Source);
} }
if (!path.Metadata.IsEmpty) if (!path.Metadata.IsEmpty)
{ {
foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder.Append("|pathmeta:") builder.Append("|pathmeta:")
.Append(kvp.Key) .Append(kvp.Key)
.Append('=') .Append('=')
.Append(kvp.Value); .Append(kvp.Value);
} }
} }
} }
if (!sbom.EnvironmentFlags.IsEmpty) if (!sbom.EnvironmentFlags.IsEmpty)
{ {
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder.Append("|env:") builder.Append("|env:")
.Append(flag.Key) .Append(flag.Key)
.Append('=') .Append('=')
.Append(flag.Value); .Append(flag.Value);
} }
} }
if (sbom.BlastRadius is not null) if (sbom.BlastRadius is not null)
{ {
builder.Append("|blast:") builder.Append("|blast:")
.Append(sbom.BlastRadius.ImpactedAssets) .Append(sbom.BlastRadius.ImpactedAssets)
.Append(',') .Append(',')
.Append(sbom.BlastRadius.ImpactedWorkloads) .Append(sbom.BlastRadius.ImpactedWorkloads)
.Append(',') .Append(',')
.Append(sbom.BlastRadius.ImpactedNamespaces) .Append(sbom.BlastRadius.ImpactedNamespaces)
.Append(',') .Append(',')
.Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty); .Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty);
if (!sbom.BlastRadius.Metadata.IsEmpty) if (!sbom.BlastRadius.Metadata.IsEmpty)
{ {
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder.Append("|blastmeta:") builder.Append("|blastmeta:")
.Append(kvp.Key) .Append(kvp.Key)
.Append('=') .Append('=')
.Append(kvp.Value); .Append(kvp.Value);
} }
} }
} }
if (!sbom.Metadata.IsEmpty) if (!sbom.Metadata.IsEmpty)
{ {
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder.Append("|sbommeta:") builder.Append("|sbommeta:")
.Append(kvp.Key) .Append(kvp.Key)
.Append('=') .Append('=')
.Append(kvp.Value); .Append(kvp.Value);
} }
} }
} }
if (dependency is not null) if (dependency is not null)
{ {
foreach (var node in dependency.Nodes foreach (var node in dependency.Nodes
.OrderBy(n => n.Identifier, StringComparer.Ordinal)) .OrderBy(n => n.Identifier, StringComparer.Ordinal))
{ {
builder.Append("|dep:") builder.Append("|dep:")
.Append(node.Identifier) .Append(node.Identifier)
.Append(':') .Append(':')
.Append(node.RuntimeOccurrences) .Append(node.RuntimeOccurrences)
.Append(':') .Append(':')
.Append(node.DevelopmentOccurrences) .Append(node.DevelopmentOccurrences)
.Append(':') .Append(':')
.Append(string.Join(',', node.Versions)); .Append(string.Join(',', node.Versions));
} }
if (!dependency.Metadata.IsEmpty) if (!dependency.Metadata.IsEmpty)
{ {
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{ {
builder.Append("|depmeta:") builder.Append("|depmeta:")
.Append(kvp.Key) .Append(kvp.Key)
.Append('=') .Append('=')
.Append(kvp.Value); .Append(kvp.Value);
} }
} }
} }
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash); return Convert.ToHexString(hash);
} }
} }

View File

@@ -1,70 +1,70 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents; using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration; namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryTaskPlan public sealed class AdvisoryTaskPlan
{ {
public AdvisoryTaskPlan( public AdvisoryTaskPlan(
AdvisoryTaskRequest request, AdvisoryTaskRequest request,
string cacheKey, string cacheKey,
string promptTemplate, string promptTemplate,
ImmutableArray<AdvisoryChunk> structuredChunks, ImmutableArray<AdvisoryChunk> structuredChunks,
ImmutableArray<AdvisoryVectorResult> vectorResults, ImmutableArray<AdvisoryVectorResult> vectorResults,
SbomContextResult? sbomContext, SbomContextResult? sbomContext,
DependencyAnalysisResult? dependencyAnalysis, DependencyAnalysisResult? dependencyAnalysis,
AdvisoryTaskBudget budget, AdvisoryTaskBudget budget,
ImmutableDictionary<string, string> metadata) ImmutableDictionary<string, string> metadata)
{ {
Request = request ?? throw new ArgumentNullException(nameof(request)); Request = request ?? throw new ArgumentNullException(nameof(request));
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey)); CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate)); PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
StructuredChunks = structuredChunks; StructuredChunks = structuredChunks;
VectorResults = vectorResults; VectorResults = vectorResults;
SbomContext = sbomContext; SbomContext = sbomContext;
DependencyAnalysis = dependencyAnalysis; DependencyAnalysis = dependencyAnalysis;
Budget = budget ?? throw new ArgumentNullException(nameof(budget)); Budget = budget ?? throw new ArgumentNullException(nameof(budget));
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
} }
public AdvisoryTaskRequest Request { get; } public AdvisoryTaskRequest Request { get; }
public string CacheKey { get; } public string CacheKey { get; }
public string PromptTemplate { get; } public string PromptTemplate { get; }
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; } public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; } public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
public SbomContextResult? SbomContext { get; } public SbomContextResult? SbomContext { get; }
public DependencyAnalysisResult? DependencyAnalysis { get; } public DependencyAnalysisResult? DependencyAnalysis { get; }
public AdvisoryTaskBudget Budget { get; } public AdvisoryTaskBudget Budget { get; }
public ImmutableDictionary<string, string> Metadata { get; } public ImmutableDictionary<string, string> Metadata { get; }
} }
public sealed class AdvisoryVectorResult public sealed class AdvisoryVectorResult
{ {
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches) public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
{ {
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query; Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
Matches = matches; Matches = matches;
} }
public string Query { get; } public string Query { get; }
public ImmutableArray<VectorRetrievalMatch> Matches { get; } public ImmutableArray<VectorRetrievalMatch> Matches { get; }
} }
public sealed class AdvisoryTaskBudget public sealed class AdvisoryTaskBudget
{ {
public int PromptTokens { get; init; } = 2048; public int PromptTokens { get; init; } = 2048;
public int CompletionTokens { get; init; } = 512; public int CompletionTokens { get; init; } = 512;
} }

View File

@@ -1,30 +1,30 @@
using System; using System;
namespace StellaOps.AdvisoryAI.Providers; namespace StellaOps.AdvisoryAI.Providers;
/// <summary> /// <summary>
/// Configuration for the SBOM context HTTP client. /// Configuration for the SBOM context HTTP client.
/// </summary> /// </summary>
public sealed class SbomContextClientOptions public sealed class SbomContextClientOptions
{ {
/// <summary> /// <summary>
/// Base address for the SBOM service. Required. /// Base address for the SBOM service. Required.
/// </summary> /// </summary>
public Uri? BaseAddress { get; set; } public Uri? BaseAddress { get; set; }
/// <summary> /// <summary>
/// Relative endpoint that returns SBOM context payloads. /// Relative endpoint that returns SBOM context payloads.
/// Defaults to <c>api/sbom/context</c>. /// Defaults to <c>api/sbom/context</c>.
/// </summary> /// </summary>
public string ContextEndpoint { get; set; } = "api/sbom/context"; public string ContextEndpoint { get; set; } = "api/sbom/context";
/// <summary> /// <summary>
/// Optional tenant identifier that should be forwarded to the SBOM service. /// Optional tenant identifier that should be forwarded to the SBOM service.
/// </summary> /// </summary>
public string? Tenant { get; set; } public string? Tenant { get; set; }
/// <summary> /// <summary>
/// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>. /// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>.
/// </summary> /// </summary>
public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant"; public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant";
} }

View File

@@ -1,234 +1,234 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Providers; namespace StellaOps.AdvisoryAI.Providers;
internal sealed class SbomContextHttpClient : ISbomContextClient internal sealed class SbomContextHttpClient : ISbomContextClient
{ {
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly HttpClient httpClient; private readonly HttpClient httpClient;
private readonly SbomContextClientOptions options; private readonly SbomContextClientOptions options;
private readonly ILogger<SbomContextHttpClient>? logger; private readonly ILogger<SbomContextHttpClient>? logger;
public SbomContextHttpClient( public SbomContextHttpClient(
HttpClient httpClient, HttpClient httpClient,
IOptions<SbomContextClientOptions> options, IOptions<SbomContextClientOptions> options,
ILogger<SbomContextHttpClient>? logger = null) ILogger<SbomContextHttpClient>? logger = null)
{ {
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (options is null) if (options is null)
{ {
throw new ArgumentNullException(nameof(options)); throw new ArgumentNullException(nameof(options));
} }
this.options = options.Value ?? 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) if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null)
{ {
this.httpClient.BaseAddress = this.options.BaseAddress; this.httpClient.BaseAddress = this.options.BaseAddress;
} }
if (this.httpClient.BaseAddress is null) if (this.httpClient.BaseAddress is null)
{ {
throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured."); throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured.");
} }
this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
this.logger = logger; this.logger = logger;
} }
public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken) public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
{ {
if (query is null) if (query is null)
{ {
throw new ArgumentNullException(nameof(query)); throw new ArgumentNullException(nameof(query));
} }
var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty; var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty;
if (endpoint.Length == 0) if (endpoint.Length == 0)
{ {
throw new InvalidOperationException("SBOM context endpoint must be configured."); throw new InvalidOperationException("SBOM context endpoint must be configured.");
} }
var requestUri = BuildRequestUri(endpoint, query); var requestUri = BuildRequestUri(endpoint, query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyTenantHeader(request); ApplyTenantHeader(request);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent) if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent)
{ {
logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri); logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri);
return null; return null;
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var content = response.Content is null var content = response.Content is null
? string.Empty ? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger?.LogWarning( logger?.LogWarning(
"SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}", "SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}",
requestUri, requestUri,
(int)response.StatusCode, (int)response.StatusCode,
content); content);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content."); var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content.");
var payload = await httpContent.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken: cancellationToken) var payload = await httpContent.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken: cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (payload is null) if (payload is null)
{ {
logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri); logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri);
return null; return null;
} }
return payload.ToDocument(); return payload.ToDocument();
} }
private Uri BuildRequestUri(string endpoint, SbomContextQuery query) private Uri BuildRequestUri(string endpoint, SbomContextQuery query)
{ {
var relative = endpoint.StartsWith("/", StringComparison.Ordinal) var relative = endpoint.StartsWith("/", StringComparison.Ordinal)
? endpoint[1..] ? endpoint[1..]
: endpoint; : endpoint;
var queryBuilder = new StringBuilder(); var queryBuilder = new StringBuilder();
AppendQuery(queryBuilder, "artifactId", query.ArtifactId); AppendQuery(queryBuilder, "artifactId", query.ArtifactId);
AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture)); AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture)); AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false"); AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false");
AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false"); AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false");
if (!string.IsNullOrWhiteSpace(query.Purl)) if (!string.IsNullOrWhiteSpace(query.Purl))
{ {
AppendQuery(queryBuilder, "purl", query.Purl!); AppendQuery(queryBuilder, "purl", query.Purl!);
} }
var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative; var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative;
return new Uri(httpClient.BaseAddress!, uriString); return new Uri(httpClient.BaseAddress!, uriString);
static void AppendQuery(StringBuilder builder, string name, string value) static void AppendQuery(StringBuilder builder, string name, string value)
{ {
if (builder.Length > 0) if (builder.Length > 0)
{ {
builder.Append('&'); builder.Append('&');
} }
builder.Append(Uri.EscapeDataString(name)); builder.Append(Uri.EscapeDataString(name));
builder.Append('='); builder.Append('=');
builder.Append(Uri.EscapeDataString(value)); builder.Append(Uri.EscapeDataString(value));
} }
} }
private void ApplyTenantHeader(HttpRequestMessage request) private void ApplyTenantHeader(HttpRequestMessage request)
{ {
if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName)) if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName))
{ {
return; return;
} }
if (!request.Headers.Contains(options.TenantHeaderName)) if (!request.Headers.Contains(options.TenantHeaderName))
{ {
request.Headers.Add(options.TenantHeaderName, options.Tenant); request.Headers.Add(options.TenantHeaderName, options.Tenant);
} }
} }
private sealed record SbomContextPayload( private sealed record SbomContextPayload(
[property: JsonPropertyName("artifactId")] string ArtifactId, [property: JsonPropertyName("artifactId")] string ArtifactId,
[property: JsonPropertyName("purl")] string? Purl, [property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions, [property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions,
[property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths, [property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags, [property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius, [property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata) [property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{ {
public SbomContextDocument ToDocument() public SbomContextDocument ToDocument()
=> new( => new(
ArtifactId, ArtifactId,
Purl, Purl,
Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(), Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(),
DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(), DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(),
EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags, EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags,
BlastRadius?.ToRecord(), BlastRadius?.ToRecord(),
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata); Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
} }
private sealed record SbomVersionPayload( private sealed record SbomVersionPayload(
[property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved, [property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved, [property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: JsonPropertyName("status")] string Status, [property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("source")] string Source, [property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable, [property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata) [property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{ {
public SbomVersionRecord ToRecord() public SbomVersionRecord ToRecord()
=> new( => new(
Version, Version,
FirstObserved, FirstObserved,
LastObserved, LastObserved,
Status, Status,
Source, Source,
IsFixAvailable, IsFixAvailable,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata); Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
} }
private sealed record SbomDependencyPathPayload( private sealed record SbomDependencyPathPayload(
[property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes, [property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime, [property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source, [property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata) [property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{ {
public SbomDependencyPathRecord ToRecord() public SbomDependencyPathRecord ToRecord()
=> new( => new(
Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(), Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(),
IsRuntime, IsRuntime,
Source, Source,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata); Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
} }
private sealed record SbomDependencyNodePayload( private sealed record SbomDependencyNodePayload(
[property: JsonPropertyName("identifier")] string Identifier, [property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("version")] string? Version) [property: JsonPropertyName("version")] string? Version)
{ {
public SbomDependencyNodeRecord ToRecord() public SbomDependencyNodeRecord ToRecord()
=> new(Identifier, Version); => new(Identifier, Version);
} }
private sealed record SbomBlastRadiusPayload( private sealed record SbomBlastRadiusPayload(
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets, [property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads, [property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces, [property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage, [property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata) [property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{ {
public SbomBlastRadiusRecord ToRecord() public SbomBlastRadiusRecord ToRecord()
=> new( => new(
ImpactedAssets, ImpactedAssets,
ImpactedWorkloads, ImpactedWorkloads,
ImpactedNamespaces, ImpactedNamespaces,
ImpactedPercentage, ImpactedPercentage,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata); Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
} }
} }

View File

@@ -1,79 +1,79 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Linq; using System.Linq;
using FluentAssertions; using FluentAssertions;
using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools; using StellaOps.AdvisoryAI.Tools;
using Xunit; using Xunit;
namespace StellaOps.AdvisoryAI.Tests; namespace StellaOps.AdvisoryAI.Tests;
public sealed class DeterministicToolsetTests public sealed class DeterministicToolsetTests
{ {
[Fact] [Fact]
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts() public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
{ {
var context = SbomContextResult.Create( var context = SbomContextResult.Create(
"artifact-123", "artifact-123",
purl: null, purl: null,
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(), versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
dependencyPaths: new[] dependencyPaths: new[]
{ {
new SbomDependencyPath( new SbomDependencyPath(
new[] new[]
{ {
new SbomDependencyNode("root", "1.0.0"), new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-a", "2.0.0"), new SbomDependencyNode("lib-a", "2.0.0"),
}, },
isRuntime: true), isRuntime: true),
new SbomDependencyPath( new SbomDependencyPath(
new[] new[]
{ {
new SbomDependencyNode("root", "1.0.0"), new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-b", "3.1.4"), new SbomDependencyNode("lib-b", "3.1.4"),
}, },
isRuntime: false), isRuntime: false),
}); });
IDeterministicToolset toolset = new DeterministicToolset(); IDeterministicToolset toolset = new DeterministicToolset();
var analysis = toolset.AnalyzeDependencies(context); var analysis = toolset.AnalyzeDependencies(context);
analysis.ArtifactId.Should().Be("artifact-123"); analysis.ArtifactId.Should().Be("artifact-123");
analysis.Metadata["path_count"].Should().Be("2"); analysis.Metadata["path_count"].Should().Be("2");
analysis.Metadata["runtime_path_count"].Should().Be("1"); analysis.Metadata["runtime_path_count"].Should().Be("1");
analysis.Metadata["development_path_count"].Should().Be("1"); analysis.Metadata["development_path_count"].Should().Be("1");
analysis.Nodes.Should().HaveCount(3); analysis.Nodes.Should().HaveCount(3);
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a"); var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
libA.RuntimeOccurrences.Should().Be(1); libA.RuntimeOccurrences.Should().Be(1);
libA.DevelopmentOccurrences.Should().Be(0); libA.DevelopmentOccurrences.Should().Be(0);
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b"); var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
libB.RuntimeOccurrences.Should().Be(0); libB.RuntimeOccurrences.Should().Be(0);
libB.DevelopmentOccurrences.Should().Be(1); libB.DevelopmentOccurrences.Should().Be(1);
} }
[Theory] [Theory]
[InlineData("semver", "1.2.3", "1.2.4", -1)] [InlineData("semver", "1.2.3", "1.2.4", -1)]
[InlineData("semver", "1.2.3", "1.2.3", 0)] [InlineData("semver", "1.2.3", "1.2.3", 0)]
[InlineData("semver", "1.2.4", "1.2.3", 1)] [InlineData("semver", "1.2.4", "1.2.3", 1)]
[InlineData("evr", "1:1.0-1", "1:1.0-2", -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.0-0", "0:2.0-0", 0)]
[InlineData("evr", "0:2.1-0", "0:2.0-5", 1)] [InlineData("evr", "0:2.1-0", "0:2.0-5", 1)]
public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected) public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected)
{ {
IDeterministicToolset toolset = new DeterministicToolset(); IDeterministicToolset toolset = new DeterministicToolset();
toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue(); toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue();
comparison.Should().Be(expected); comparison.Should().Be(expected);
} }
[Theory] [Theory]
[InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")] [InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")]
[InlineData("semver", "2.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", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")]
[InlineData("evr", "1:3.4-1", ">=1:3.0-0")] [InlineData("evr", "1:3.4-1", ">=1:3.0-0")]
public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range) public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range)
{ {
IDeterministicToolset toolset = new DeterministicToolset(); IDeterministicToolset toolset = new DeterministicToolset();
toolset.SatisfiesRange(scheme, version, range).Should().BeTrue(); toolset.SatisfiesRange(scheme, version, range).Should().BeTrue();
} }
} }

View File

@@ -1,144 +1,144 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Providers; using StellaOps.AdvisoryAI.Providers;
using Xunit; using Xunit;
namespace StellaOps.AdvisoryAI.Tests; namespace StellaOps.AdvisoryAI.Tests;
public sealed class SbomContextHttpClientTests public sealed class SbomContextHttpClientTests
{ {
[Fact] [Fact]
public async Task GetContextAsync_MapsPayloadToDocument() public async Task GetContextAsync_MapsPayloadToDocument()
{ {
const string payload = """ const string payload = """
{ {
"artifactId": "artifact-001", "artifactId": "artifact-001",
"purl": "pkg:npm/react@18.3.0", "purl": "pkg:npm/react@18.3.0",
"versions": [ "versions": [
{ {
"version": "18.3.0", "version": "18.3.0",
"firstObserved": "2025-10-01T00:00:00Z", "firstObserved": "2025-10-01T00:00:00Z",
"lastObserved": null, "lastObserved": null,
"status": "affected", "status": "affected",
"source": "inventory", "source": "inventory",
"isFixAvailable": false, "isFixAvailable": false,
"metadata": { "note": "current" } "metadata": { "note": "current" }
} }
], ],
"dependencyPaths": [ "dependencyPaths": [
{ {
"nodes": [ "nodes": [
{ "identifier": "app", "version": "1.0.0" }, { "identifier": "app", "version": "1.0.0" },
{ "identifier": "react", "version": "18.3.0" } { "identifier": "react", "version": "18.3.0" }
], ],
"isRuntime": true, "isRuntime": true,
"source": "scanner", "source": "scanner",
"metadata": { "scope": "production" } "metadata": { "scope": "production" }
} }
], ],
"environmentFlags": { "environmentFlags": {
"environment/prod": "true" "environment/prod": "true"
}, },
"blastRadius": { "blastRadius": {
"impactedAssets": 10, "impactedAssets": 10,
"impactedWorkloads": 4, "impactedWorkloads": 4,
"impactedNamespaces": 2, "impactedNamespaces": 2,
"impactedPercentage": 0.25, "impactedPercentage": 0.25,
"metadata": { "note": "simulated" } "metadata": { "note": "simulated" }
}, },
"metadata": { "metadata": {
"source": "sbom-service" "source": "sbom-service"
} }
} }
"""; """;
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{ {
Content = new StringContent(payload, Encoding.UTF8, "application/json") Content = new StringContent(payload, Encoding.UTF8, "application/json")
}); });
var httpClient = new HttpClient(handler) var httpClient = new HttpClient(handler)
{ {
BaseAddress = new Uri("https://sbom.example/") BaseAddress = new Uri("https://sbom.example/")
}; };
var options = Options.Create(new SbomContextClientOptions var options = Options.Create(new SbomContextClientOptions
{ {
ContextEndpoint = "api/sbom/context", ContextEndpoint = "api/sbom/context",
Tenant = "tenant-alpha", Tenant = "tenant-alpha",
TenantHeaderName = "X-StellaOps-Tenant" TenantHeaderName = "X-StellaOps-Tenant"
}); });
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance); var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true); 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); var document = await client.GetContextAsync(query, CancellationToken.None);
Assert.NotNull(document); Assert.NotNull(document);
Assert.Equal("artifact-001", document!.ArtifactId); Assert.Equal("artifact-001", document!.ArtifactId);
Assert.Equal("pkg:npm/react@18.3.0", document.Purl); Assert.Equal("pkg:npm/react@18.3.0", document.Purl);
Assert.Single(document.Versions); Assert.Single(document.Versions);
Assert.Single(document.DependencyPaths); Assert.Single(document.DependencyPaths);
Assert.Single(document.EnvironmentFlags); Assert.Single(document.EnvironmentFlags);
Assert.NotNull(document.BlastRadius); Assert.NotNull(document.BlastRadius);
Assert.Equal("sbom-service", document.Metadata["source"]); Assert.Equal("sbom-service", document.Metadata["source"]);
Assert.NotNull(handler.LastRequest); Assert.NotNull(handler.LastRequest);
Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single()); Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single());
Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query); Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query);
Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", 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("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query);
Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query); Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query);
} }
[Fact] [Fact]
public async Task GetContextAsync_ReturnsNullOnNotFound() public async Task GetContextAsync_ReturnsNullOnNotFound()
{ {
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions()); var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance); var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None); var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None);
Assert.Null(result); Assert.Null(result);
} }
[Fact] [Fact]
public async Task GetContextAsync_ThrowsForServerError() public async Task GetContextAsync_ThrowsForServerError()
{ {
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{ {
Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json") Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json")
}); });
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") }; var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions()); var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance); var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None)); await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None));
} }
private sealed class StubHttpMessageHandler : HttpMessageHandler private sealed class StubHttpMessageHandler : HttpMessageHandler
{ {
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder; private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{ {
this.responder = responder ?? throw new ArgumentNullException(nameof(responder)); this.responder = responder ?? throw new ArgumentNullException(nameof(responder));
} }
public HttpRequestMessage? LastRequest { get; private set; } public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
LastRequest = request; LastRequest = request;
return Task.FromResult(responder(request)); return Task.FromResult(responder(request));
} }
} }
} }

View File

@@ -10,30 +10,30 @@ using StellaOps.AdvisoryAI.Tools;
using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents; using StellaOps.AdvisoryAI.Documents;
using Xunit; using Xunit;
namespace StellaOps.AdvisoryAI.Tests; namespace StellaOps.AdvisoryAI.Tests;
public sealed class ToolsetServiceCollectionExtensionsTests public sealed class ToolsetServiceCollectionExtensionsTests
{ {
[Fact] [Fact]
public void AddAdvisoryDeterministicToolset_RegistersSingleton() public void AddAdvisoryDeterministicToolset_RegistersSingleton()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddAdvisoryDeterministicToolset(); services.AddAdvisoryDeterministicToolset();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
var toolsetA = provider.GetRequiredService<IDeterministicToolset>(); var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
var toolsetB = provider.GetRequiredService<IDeterministicToolset>(); var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
Assert.Same(toolsetA, toolsetB); Assert.Same(toolsetA, toolsetB);
} }
[Fact] [Fact]
public void AddAdvisoryPipeline_RegistersOrchestrator() public void AddAdvisoryPipeline_RegistersOrchestrator()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSbomContext(options => services.AddSbomContext(options =>
{ {
options.BaseAddress = new Uri("https://sbom.example/"); options.BaseAddress = new Uri("https://sbom.example/");

View File

@@ -1,25 +1,25 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public static class AocForbiddenKeys public static class AocForbiddenKeys
{ {
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[] private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[]
{ {
"severity", "severity",
"cvss", "cvss",
"cvss_vector", "cvss_vector",
"effective_status", "effective_status",
"effective_range", "effective_range",
"merged_from", "merged_from",
"consensus_provider", "consensus_provider",
"reachability", "reachability",
"asset_criticality", "asset_criticality",
"risk_score", "risk_score",
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); }.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName); public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName);
public static bool IsDerivedField(string propertyName) public static bool IsDerivedField(string propertyName)
=> propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase); => propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase);
} }

View File

@@ -1,17 +1,17 @@
using System; using System;
using System.Collections.Immutable; using System.Collections.Immutable;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public sealed class AocGuardException : Exception public sealed class AocGuardException : Exception
{ {
public AocGuardException(AocGuardResult result) public AocGuardException(AocGuardResult result)
: base("AOC guard validation failed.") : base("AOC guard validation failed.")
{ {
Result = result ?? throw new ArgumentNullException(nameof(result)); Result = result ?? throw new ArgumentNullException(nameof(result));
} }
public AocGuardResult Result { get; } public AocGuardResult Result { get; }
public ImmutableArray<AocViolation> Violations => Result.Violations; public ImmutableArray<AocViolation> Violations => Result.Violations;
} }

View File

@@ -1,22 +1,22 @@
using System.Text.Json; using System.Text.Json;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public static class AocGuardExtensions public static class AocGuardExtensions
{ {
public static AocGuardResult ValidateOrThrow(this IAocGuard guard, JsonElement document, AocGuardOptions? options = null) public static AocGuardResult ValidateOrThrow(this IAocGuard guard, JsonElement document, AocGuardOptions? options = null)
{ {
if (guard is null) if (guard is null)
{ {
throw new ArgumentNullException(nameof(guard)); throw new ArgumentNullException(nameof(guard));
} }
var result = guard.Validate(document, options); var result = guard.Validate(document, options);
if (!result.IsValid) if (!result.IsValid)
{ {
throw new AocGuardException(result); throw new AocGuardException(result);
} }
return result; return result;
} }
} }

View File

@@ -1,8 +1,8 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Linq; using System.Linq;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public sealed record AocGuardOptions public sealed record AocGuardOptions
{ {
private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[] private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[]

View File

@@ -1,14 +1,14 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public sealed record AocGuardResult(bool IsValid, ImmutableArray<AocViolation> Violations) public sealed record AocGuardResult(bool IsValid, ImmutableArray<AocViolation> Violations)
{ {
public static AocGuardResult Success { get; } = new(true, ImmutableArray<AocViolation>.Empty); public static AocGuardResult Success { get; } = new(true, ImmutableArray<AocViolation>.Empty);
public static AocGuardResult FromViolations(IEnumerable<AocViolation> violations) public static AocGuardResult FromViolations(IEnumerable<AocViolation> violations)
{ {
var array = violations.ToImmutableArray(); var array = violations.ToImmutableArray();
return array.IsDefaultOrEmpty ? Success : new(false, array); return array.IsDefaultOrEmpty ? Success : new(false, array);
} }
} }

View File

@@ -1,13 +1,13 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public sealed record AocViolation( public sealed record AocViolation(
[property: JsonPropertyName("code")] AocViolationCode Code, [property: JsonPropertyName("code")] AocViolationCode Code,
[property: JsonPropertyName("errorCode")] string ErrorCode, [property: JsonPropertyName("errorCode")] string ErrorCode,
[property: JsonPropertyName("path")] string Path, [property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("message")] string Message) [property: JsonPropertyName("message")] string Message)
{ {
public static AocViolation Create(AocViolationCode code, string path, string message) public static AocViolation Create(AocViolationCode code, string path, string message)
=> new(code, code.ToErrorCode(), path, message); => new(code, code.ToErrorCode(), path, message);
} }

View File

@@ -1,34 +1,34 @@
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public enum AocViolationCode public enum AocViolationCode
{ {
None = 0, None = 0,
ForbiddenField, ForbiddenField,
MergeAttempt, MergeAttempt,
IdempotencyViolation, IdempotencyViolation,
MissingProvenance, MissingProvenance,
SignatureInvalid, SignatureInvalid,
DerivedFindingDetected, DerivedFindingDetected,
UnknownField, UnknownField,
MissingRequiredField, MissingRequiredField,
InvalidTenant, InvalidTenant,
InvalidSignatureMetadata, InvalidSignatureMetadata,
} }
public static class AocViolationCodeExtensions public static class AocViolationCodeExtensions
{ {
public static string ToErrorCode(this AocViolationCode code) => code switch public static string ToErrorCode(this AocViolationCode code) => code switch
{ {
AocViolationCode.ForbiddenField => "ERR_AOC_001", AocViolationCode.ForbiddenField => "ERR_AOC_001",
AocViolationCode.MergeAttempt => "ERR_AOC_002", AocViolationCode.MergeAttempt => "ERR_AOC_002",
AocViolationCode.IdempotencyViolation => "ERR_AOC_003", AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
AocViolationCode.MissingProvenance => "ERR_AOC_004", AocViolationCode.MissingProvenance => "ERR_AOC_004",
AocViolationCode.SignatureInvalid => "ERR_AOC_005", AocViolationCode.SignatureInvalid => "ERR_AOC_005",
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006", AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
AocViolationCode.UnknownField => "ERR_AOC_007", AocViolationCode.UnknownField => "ERR_AOC_007",
AocViolationCode.MissingRequiredField => "ERR_AOC_004", AocViolationCode.MissingRequiredField => "ERR_AOC_004",
AocViolationCode.InvalidTenant => "ERR_AOC_004", AocViolationCode.InvalidTenant => "ERR_AOC_004",
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_005", AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_005",
_ => "ERR_AOC_000", _ => "ERR_AOC_000",
}; };
} }

View File

@@ -1,16 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Text.Json; using System.Text.Json;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public interface IAocGuard public interface IAocGuard
{ {
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null); AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
} }
public sealed class AocWriteGuard : IAocGuard public sealed class AocWriteGuard : IAocGuard
{ {
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null) public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
{ {
options ??= AocGuardOptions.Default; options ??= AocGuardOptions.Default;
@@ -22,13 +22,13 @@ public sealed class AocWriteGuard : IAocGuard
{ {
presentTopLevel.Add(property.Name); presentTopLevel.Add(property.Name);
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name)) if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
{ {
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents.")); violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
continue; continue;
} }
if (AocForbiddenKeys.IsDerivedField(property.Name)) if (AocForbiddenKeys.IsDerivedField(property.Name))
{ {
violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion.")); violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion."));
} }
@@ -43,92 +43,92 @@ public sealed class AocWriteGuard : IAocGuard
foreach (var required in options.RequiredTopLevelFields) foreach (var required in options.RequiredTopLevelFields)
{ {
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing.")); violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
continue; continue;
} }
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase)) if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
{ {
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString())) if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
{ {
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string.")); violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
} }
} }
} }
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object) if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
{ {
if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString())) if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString()))
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required.")); violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required."));
} }
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object) if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
{ {
if (options.RequireSignatureMetadata) if (options.RequireSignatureMetadata)
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required.")); violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
} }
} }
else if (options.RequireSignatureMetadata) else if (options.RequireSignatureMetadata)
{ {
ValidateSignature(signature, violations); ValidateSignature(signature, violations);
} }
} }
else else
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required.")); violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
} }
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object) if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
{ {
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved.")); violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
} }
} }
else else
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required.")); violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
} }
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object) if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
{ {
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required.")); violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
} }
return AocGuardResult.FromViolations(violations); return AocGuardResult.FromViolations(violations);
} }
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations) private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations)
{ {
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{ {
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean.")); violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
return; return;
} }
var signaturePresent = presentElement.GetBoolean(); var signaturePresent = presentElement.GetBoolean();
if (!signaturePresent) if (!signaturePresent)
{ {
return; return;
} }
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString())) if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
{ {
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present.")); violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
} }
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString())) if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
{ {
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present.")); violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
} }
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString())) if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
{ {
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present.")); violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
} }
} }
} }

View File

@@ -1,17 +1,17 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Aoc; namespace StellaOps.Aoc;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddAocGuard(this IServiceCollection services) public static IServiceCollection AddAocGuard(this IServiceCollection services)
{ {
if (services is null) if (services is null)
{ {
throw new ArgumentNullException(nameof(services)); throw new ArgumentNullException(nameof(services));
} }
services.AddSingleton<IAocGuard, AocWriteGuard>(); services.AddSingleton<IAocGuard, AocWriteGuard>();
return services; return services;
} }
} }

View File

@@ -1,32 +1,32 @@
using System.Text.Json; using System.Text.Json;
using StellaOps.Aoc; using StellaOps.Aoc;
namespace StellaOps.Aoc.Tests; namespace StellaOps.Aoc.Tests;
public sealed class AocWriteGuardTests public sealed class AocWriteGuardTests
{ {
private static readonly AocWriteGuard Guard = new(); private static readonly AocWriteGuard Guard = new();
[Fact] [Fact]
public void Validate_ReturnsSuccess_ForMinimalValidDocument() public void Validate_ReturnsSuccess_ForMinimalValidDocument()
{ {
using var document = JsonDocument.Parse(""" using var document = JsonDocument.Parse("""
{ {
"tenant": "default", "tenant": "default",
"source": {"vendor": "osv"}, "source": {"vendor": "osv"},
"upstream": { "upstream": {
"upstream_id": "GHSA-xxxx", "upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc", "content_hash": "sha256:abc",
"signature": { "present": false } "signature": { "present": false }
}, },
"content": { "content": {
"format": "OSV", "format": "OSV",
"raw": {"id": "GHSA-xxxx"} "raw": {"id": "GHSA-xxxx"}
}, },
"linkset": {} "linkset": {}
} }
"""); """);
var result = Guard.Validate(document.RootElement); var result = Guard.Validate(document.RootElement);
Assert.True(result.IsValid); Assert.True(result.IsValid);
@@ -63,32 +63,32 @@ public sealed class AocWriteGuardTests
Assert.Empty(result.Violations); Assert.Empty(result.Violations);
} }
[Fact] [Fact]
public void Validate_FlagsMissingTenant() public void Validate_FlagsMissingTenant()
{ {
using var document = JsonDocument.Parse(""" using var document = JsonDocument.Parse("""
{ {
"source": {"vendor": "osv"}, "source": {"vendor": "osv"},
"upstream": { "upstream": {
"upstream_id": "GHSA-xxxx", "upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc", "content_hash": "sha256:abc",
"signature": { "present": false } "signature": { "present": false }
}, },
"content": { "content": {
"format": "OSV", "format": "OSV",
"raw": {"id": "GHSA-xxxx"} "raw": {"id": "GHSA-xxxx"}
}, },
"linkset": {} "linkset": {}
} }
"""); """);
var result = Guard.Validate(document.RootElement); var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid); Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_004" && v.Path == "/tenant"); Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_004" && v.Path == "/tenant");
} }
[Fact] [Fact]
public void Validate_FlagsForbiddenField() public void Validate_FlagsForbiddenField()
{ {
using var document = JsonDocument.Parse(""" using var document = JsonDocument.Parse("""
@@ -100,18 +100,18 @@ public sealed class AocWriteGuardTests
"upstream": { "upstream": {
"upstream_id": "GHSA-xxxx", "upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc", "content_hash": "sha256:abc",
"signature": { "present": false } "signature": { "present": false }
}, },
"content": { "content": {
"format": "OSV", "format": "OSV",
"raw": {"id": "GHSA-xxxx"} "raw": {"id": "GHSA-xxxx"}
}, },
"linkset": {} "linkset": {}
} }
"""); """);
var result = Guard.Validate(document.RootElement); var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid); Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_001" && v.Path == "/severity"); Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_001" && v.Path == "/severity");
} }
@@ -180,23 +180,23 @@ public sealed class AocWriteGuardTests
using var document = JsonDocument.Parse(""" using var document = JsonDocument.Parse("""
{ {
"tenant": "default", "tenant": "default",
"source": {"vendor": "osv"}, "source": {"vendor": "osv"},
"upstream": { "upstream": {
"upstream_id": "GHSA-xxxx", "upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc", "content_hash": "sha256:abc",
"signature": { "present": true, "format": "dsse" } "signature": { "present": true, "format": "dsse" }
}, },
"content": { "content": {
"format": "OSV", "format": "OSV",
"raw": {"id": "GHSA-xxxx"} "raw": {"id": "GHSA-xxxx"}
}, },
"linkset": {} "linkset": {}
} }
"""); """);
var result = Guard.Validate(document.RootElement); var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid); Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_005" && v.Path.Contains("/sig")); Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_005" && v.Path.Contains("/sig"));
} }
} }

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Aoc.Tests; namespace StellaOps.Aoc.Tests;
public class UnitTest1 public class UnitTest1
{ {
[Fact] [Fact]
public void Test1() public void Test1()
{ {
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Verification; namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationResult public sealed class AttestorVerificationResult
{ {
public bool Ok { get; init; } public bool Ok { get; init; }
public string? Uuid { get; init; } public string? Uuid { get; init; }
public long? Index { get; init; } public long? Index { get; init; }
public string? LogUrl { get; init; } public string? LogUrl { get; init; }
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,37 +15,37 @@ using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage; using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission; using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Tests.Support; using StellaOps.Attestor.Tests.Support;
using Xunit; using Xunit;
namespace StellaOps.Attestor.Tests; namespace StellaOps.Attestor.Tests;
public sealed class AttestorSubmissionServiceTests public sealed class AttestorSubmissionServiceTests
{ {
[Fact] [Fact]
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle() public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
{ {
var options = Options.Create(new AttestorOptions var options = Options.Create(new AttestorOptions
{ {
Redis = new AttestorOptions.RedisOptions Redis = new AttestorOptions.RedisOptions
{ {
Url = string.Empty Url = string.Empty
}, },
Rekor = new AttestorOptions.RekorOptions Rekor = new AttestorOptions.RekorOptions
{ {
Primary = new AttestorOptions.RekorBackendOptions Primary = new AttestorOptions.RekorBackendOptions
{ {
Url = "https://rekor.stellaops.test", Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
} }
} }
}); });
var canonicalizer = new DefaultDsseCanonicalizer(); var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer); var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository(); var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore(); var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>()); var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>()); var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink(); var auditSink = new InMemoryAttestorAuditSink();
@@ -66,21 +66,21 @@ public sealed class AttestorSubmissionServiceTests
logger, logger,
TimeProvider.System, TimeProvider.System,
metrics); metrics);
var request = CreateValidRequest(canonicalizer); var request = CreateValidRequest(canonicalizer);
var context = new SubmissionContext var context = new SubmissionContext
{ {
CallerSubject = "urn:stellaops:signer", CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor", CallerAudience = "attestor",
CallerClientId = "signer-service", CallerClientId = "signer-service",
CallerTenant = "default", CallerTenant = "default",
ClientCertificate = null, ClientCertificate = null,
MtlsThumbprint = "00" MtlsThumbprint = "00"
}; };
var first = await service.SubmitAsync(request, context); var first = await service.SubmitAsync(request, context);
var second = await service.SubmitAsync(request, context); var second = await service.SubmitAsync(request, context);
Assert.NotNull(first.Uuid); Assert.NotNull(first.Uuid);
Assert.Equal(first.Uuid, second.Uuid); Assert.Equal(first.Uuid, second.Uuid);
@@ -89,43 +89,43 @@ public sealed class AttestorSubmissionServiceTests
Assert.Equal(first.Uuid, stored!.RekorUuid); Assert.Equal(first.Uuid, stored!.RekorUuid);
Assert.Single(verificationCache.InvalidatedSubjects); Assert.Single(verificationCache.InvalidatedSubjects);
Assert.Equal(request.Meta.Artifact.Sha256, verificationCache.InvalidatedSubjects[0]); Assert.Equal(request.Meta.Artifact.Sha256, verificationCache.InvalidatedSubjects[0]);
} }
[Fact] [Fact]
public async Task Validator_ThrowsWhenModeNotAllowed() public async Task Validator_ThrowsWhenModeNotAllowed()
{ {
var canonicalizer = new DefaultDsseCanonicalizer(); var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" }); var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
var request = CreateValidRequest(canonicalizer); var request = CreateValidRequest(canonicalizer);
request.Bundle.Mode = "keyless"; request.Bundle.Mode = "keyless";
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request)); await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
} }
[Fact] [Fact]
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested() public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
{ {
var options = Options.Create(new AttestorOptions var options = Options.Create(new AttestorOptions
{ {
Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions Rekor = new AttestorOptions.RekorOptions
{ {
Primary = new AttestorOptions.RekorBackendOptions Primary = new AttestorOptions.RekorBackendOptions
{ {
Url = "https://rekor.primary.test", Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
} }
} }
}); });
var canonicalizer = new DefaultDsseCanonicalizer(); var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer); var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository(); var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore(); var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>()); var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>()); var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink(); var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient(); var witnessClient = new TestTransparencyWitnessClient();
@@ -145,53 +145,53 @@ public sealed class AttestorSubmissionServiceTests
logger, logger,
TimeProvider.System, TimeProvider.System,
metrics); metrics);
var request = CreateValidRequest(canonicalizer); var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror"; request.Meta.LogPreference = "mirror";
var context = new SubmissionContext var context = new SubmissionContext
{ {
CallerSubject = "urn:stellaops:signer", CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor", CallerAudience = "attestor",
CallerClientId = "signer-service", CallerClientId = "signer-service",
CallerTenant = "default" CallerTenant = "default"
}; };
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context)); var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
Assert.Equal("mirror_disabled", ex.Code); Assert.Equal("mirror_disabled", ex.Code);
} }
[Fact] [Fact]
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth() public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
{ {
var options = Options.Create(new AttestorOptions var options = Options.Create(new AttestorOptions
{ {
Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions Rekor = new AttestorOptions.RekorOptions
{ {
Primary = new AttestorOptions.RekorBackendOptions Primary = new AttestorOptions.RekorBackendOptions
{ {
Url = "https://rekor.primary.test", Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
}, },
Mirror = new AttestorOptions.RekorMirrorOptions Mirror = new AttestorOptions.RekorMirrorOptions
{ {
Enabled = true, Enabled = true,
Url = "https://rekor.mirror.test", Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
} }
} }
}); });
var canonicalizer = new DefaultDsseCanonicalizer(); var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer); var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository(); var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore(); var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>()); var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>()); var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink(); var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient(); var witnessClient = new TestTransparencyWitnessClient();
@@ -211,56 +211,56 @@ public sealed class AttestorSubmissionServiceTests
logger, logger,
TimeProvider.System, TimeProvider.System,
metrics); metrics);
var request = CreateValidRequest(canonicalizer); var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "both"; request.Meta.LogPreference = "both";
var context = new SubmissionContext var context = new SubmissionContext
{ {
CallerSubject = "urn:stellaops:signer", CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor", CallerAudience = "attestor",
CallerClientId = "signer-service", CallerClientId = "signer-service",
CallerTenant = "default" CallerTenant = "default"
}; };
var result = await service.SubmitAsync(request, context); var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Mirror); Assert.NotNull(result.Mirror);
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid)); Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
Assert.Equal("included", result.Mirror.Status); Assert.Equal("included", result.Mirror.Status);
} }
[Fact] [Fact]
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror() public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
{ {
var options = Options.Create(new AttestorOptions var options = Options.Create(new AttestorOptions
{ {
Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions Rekor = new AttestorOptions.RekorOptions
{ {
Primary = new AttestorOptions.RekorBackendOptions Primary = new AttestorOptions.RekorBackendOptions
{ {
Url = "https://rekor.primary.test", Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
}, },
Mirror = new AttestorOptions.RekorMirrorOptions Mirror = new AttestorOptions.RekorMirrorOptions
{ {
Enabled = true, Enabled = true,
Url = "https://rekor.mirror.test", Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000, ProofTimeoutMs = 1000,
PollIntervalMs = 50, PollIntervalMs = 50,
MaxAttempts = 2 MaxAttempts = 2
} }
} }
}); });
var canonicalizer = new DefaultDsseCanonicalizer(); var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer); var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository(); var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore(); var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>()); var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>()); var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink(); var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient(); var witnessClient = new TestTransparencyWitnessClient();
@@ -280,24 +280,24 @@ public sealed class AttestorSubmissionServiceTests
logger, logger,
TimeProvider.System, TimeProvider.System,
metrics); metrics);
var request = CreateValidRequest(canonicalizer); var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror"; request.Meta.LogPreference = "mirror";
var context = new SubmissionContext var context = new SubmissionContext
{ {
CallerSubject = "urn:stellaops:signer", CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor", CallerAudience = "attestor",
CallerClientId = "signer-service", CallerClientId = "signer-service",
CallerTenant = "default" CallerTenant = "default"
}; };
var result = await service.SubmitAsync(request, context); var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Uuid); Assert.NotNull(result.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256); var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored); Assert.NotNull(stored);
Assert.Equal("mirror", stored!.Log.Backend); Assert.Equal("mirror", stored!.Log.Backend);
Assert.Null(result.Mirror); Assert.Null(result.Mirror);
} }
@@ -323,36 +323,36 @@ public sealed class AttestorSubmissionServiceTests
var request = new AttestorSubmissionRequest var request = new AttestorSubmissionRequest
{ {
Bundle = new AttestorSubmissionRequest.SubmissionBundle Bundle = new AttestorSubmissionRequest.SubmissionBundle
{ {
Mode = "keyless", Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope Dsse = new AttestorSubmissionRequest.DsseEnvelope
{ {
PayloadType = "application/vnd.in-toto+json", PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")), PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures = Signatures =
{ {
new AttestorSubmissionRequest.DsseSignature new AttestorSubmissionRequest.DsseSignature
{ {
KeyId = "test", KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
} }
} }
} }
}, },
Meta = new AttestorSubmissionRequest.SubmissionMeta Meta = new AttestorSubmissionRequest.SubmissionMeta
{ {
Artifact = new AttestorSubmissionRequest.ArtifactInfo Artifact = new AttestorSubmissionRequest.ArtifactInfo
{ {
Sha256 = new string('a', 64), Sha256 = new string('a', 64),
Kind = "sbom" Kind = "sbom"
}, },
LogPreference = "primary", LogPreference = "primary",
Archive = false Archive = false
} }
}; };
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult(); var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant(); request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request; return request;
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,21 @@
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
using Xunit; using Xunit;
namespace StellaOps.Auth.Abstractions.Tests; namespace StellaOps.Auth.Abstractions.Tests;
#pragma warning disable CS0618 #pragma warning disable CS0618
public class StellaOpsScopesTests public class StellaOpsScopesTests
{ {
[Theory] [Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)] [InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)] [InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.AdvisoryAiView)] [InlineData(StellaOpsScopes.AdvisoryAiView)]
[InlineData(StellaOpsScopes.AdvisoryAiOperate)] [InlineData(StellaOpsScopes.AdvisoryAiOperate)]
[InlineData(StellaOpsScopes.AdvisoryAiAdmin)] [InlineData(StellaOpsScopes.AdvisoryAiAdmin)]
[InlineData(StellaOpsScopes.VexRead)] [InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)] [InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)] [InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.SignalsRead)] [InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)] [InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)] [InlineData(StellaOpsScopes.SignalsAdmin)]
@@ -25,23 +25,23 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.PolicyWrite)] [InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)] [InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)] [InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)] [InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)] [InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)] [InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyPublish)] [InlineData(StellaOpsScopes.PolicyPublish)]
[InlineData(StellaOpsScopes.PolicyPromote)] [InlineData(StellaOpsScopes.PolicyPromote)]
[InlineData(StellaOpsScopes.PolicyAudit)] [InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)] [InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)] [InlineData(StellaOpsScopes.PolicySimulate)]
[InlineData(StellaOpsScopes.FindingsRead)] [InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)] [InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.GraphRead)] [InlineData(StellaOpsScopes.GraphRead)]
[InlineData(StellaOpsScopes.VulnView)] [InlineData(StellaOpsScopes.VulnView)]
[InlineData(StellaOpsScopes.VulnInvestigate)] [InlineData(StellaOpsScopes.VulnInvestigate)]
[InlineData(StellaOpsScopes.VulnOperate)] [InlineData(StellaOpsScopes.VulnOperate)]
[InlineData(StellaOpsScopes.VulnAudit)] [InlineData(StellaOpsScopes.VulnAudit)]
[InlineData(StellaOpsScopes.VulnRead)] [InlineData(StellaOpsScopes.VulnRead)]
[InlineData(StellaOpsScopes.GraphWrite)] [InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)] [InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)] [InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)] [InlineData(StellaOpsScopes.OrchRead)]
@@ -73,8 +73,8 @@ public class StellaOpsScopesTests
Assert.Contains(scope, StellaOpsScopes.All); Assert.Contains(scope, StellaOpsScopes.All);
} }
[Theory] [Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)] [InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)] [InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)] [InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)] [InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]

View File

@@ -1,54 +1,54 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions; namespace StellaOps.Auth.Abstractions;
/// <summary> /// <summary>
/// Canonical scope names supported by StellaOps services. /// Canonical scope names supported by StellaOps services.
/// </summary> /// </summary>
public static class StellaOpsScopes public static class StellaOpsScopes
{ {
/// <summary> /// <summary>
/// Scope required to trigger Concelier jobs. /// Scope required to trigger Concelier jobs.
/// </summary> /// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger"; public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary> /// <summary>
/// Scope required to manage Concelier merge operations. /// Scope required to manage Concelier merge operations.
/// </summary> /// </summary>
public const string ConcelierMerge = "concelier.merge"; public const string ConcelierMerge = "concelier.merge";
/// <summary> /// <summary>
/// Scope granting administrative access to Authority user management. /// Scope granting administrative access to Authority user management.
/// </summary> /// </summary>
public const string AuthorityUsersManage = "authority.users.manage"; public const string AuthorityUsersManage = "authority.users.manage";
/// <summary> /// <summary>
/// Scope granting administrative access to Authority client registrations. /// Scope granting administrative access to Authority client registrations.
/// </summary> /// </summary>
public const string AuthorityClientsManage = "authority.clients.manage"; public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary> /// <summary>
/// Scope granting read-only access to Authority audit logs. /// Scope granting read-only access to Authority audit logs.
/// </summary> /// </summary>
public const string AuthorityAuditRead = "authority.audit.read"; public const string AuthorityAuditRead = "authority.audit.read";
/// <summary> /// <summary>
/// Synthetic scope representing trusted network bypass. /// Synthetic scope representing trusted network bypass.
/// </summary> /// </summary>
public const string Bypass = "stellaops.bypass"; public const string Bypass = "stellaops.bypass";
/// <summary> /// <summary>
/// Scope granting read-only access to console UX features. /// Scope granting read-only access to console UX features.
/// </summary> /// </summary>
public const string UiRead = "ui.read"; public const string UiRead = "ui.read";
/// <summary> /// <summary>
/// Scope granting permission to approve exceptions. /// Scope granting permission to approve exceptions.
/// </summary> /// </summary>
public const string ExceptionsApprove = "exceptions:approve"; public const string ExceptionsApprove = "exceptions:approve";
/// <summary> /// <summary>
/// Scope granting read-only access to raw advisory ingestion data. /// Scope granting read-only access to raw advisory ingestion data.
/// </summary> /// </summary>
public const string AdvisoryRead = "advisory:read"; public const string AdvisoryRead = "advisory:read";
@@ -72,34 +72,34 @@ public static class StellaOpsScopes
/// Scope granting administrative control over Advisory AI configuration and profiles. /// Scope granting administrative control over Advisory AI configuration and profiles.
/// </summary> /// </summary>
public const string AdvisoryAiAdmin = "advisory-ai:admin"; public const string AdvisoryAiAdmin = "advisory-ai:admin";
/// <summary> /// <summary>
/// Scope granting read-only access to raw VEX ingestion data. /// Scope granting read-only access to raw VEX ingestion data.
/// </summary> /// </summary>
public const string VexRead = "vex:read"; public const string VexRead = "vex:read";
/// <summary> /// <summary>
/// Scope granting write access for raw VEX ingestion. /// Scope granting write access for raw VEX ingestion.
/// </summary> /// </summary>
public const string VexIngest = "vex:ingest"; public const string VexIngest = "vex:ingest";
/// <summary> /// <summary>
/// Scope granting permission to execute aggregation-only contract verification. /// Scope granting permission to execute aggregation-only contract verification.
/// </summary> /// </summary>
public const string AocVerify = "aoc:verify"; public const string AocVerify = "aoc:verify";
/// <summary> /// <summary>
/// Scope granting read-only access to reachability signals. /// Scope granting read-only access to reachability signals.
/// </summary> /// </summary>
public const string SignalsRead = "signals:read"; public const string SignalsRead = "signals:read";
/// <summary> /// <summary>
/// Scope granting permission to write reachability signals. /// Scope granting permission to write reachability signals.
/// </summary> /// </summary>
public const string SignalsWrite = "signals:write"; public const string SignalsWrite = "signals:write";
/// <summary> /// <summary>
/// Scope granting administrative access to reachability signal ingestion. /// Scope granting administrative access to reachability signal ingestion.
/// </summary> /// </summary>
public const string SignalsAdmin = "signals:admin"; public const string SignalsAdmin = "signals:admin";
@@ -122,38 +122,38 @@ public static class StellaOpsScopes
/// Scope granting permission to create or edit policy drafts. /// Scope granting permission to create or edit policy drafts.
/// </summary> /// </summary>
public const string PolicyWrite = "policy:write"; public const string PolicyWrite = "policy:write";
/// <summary> /// <summary>
/// Scope granting permission to author Policy Studio workspaces. /// Scope granting permission to author Policy Studio workspaces.
/// </summary> /// </summary>
public const string PolicyAuthor = "policy:author"; public const string PolicyAuthor = "policy:author";
/// <summary> /// <summary>
/// Scope granting permission to edit policy configurations. /// Scope granting permission to edit policy configurations.
/// </summary> /// </summary>
public const string PolicyEdit = "policy:edit"; public const string PolicyEdit = "policy:edit";
/// <summary> /// <summary>
/// Scope granting read-only access to policy metadata. /// Scope granting read-only access to policy metadata.
/// </summary> /// </summary>
public const string PolicyRead = "policy:read"; public const string PolicyRead = "policy:read";
/// <summary> /// <summary>
/// Scope granting permission to review Policy Studio drafts. /// Scope granting permission to review Policy Studio drafts.
/// </summary> /// </summary>
public const string PolicyReview = "policy:review"; public const string PolicyReview = "policy:review";
/// <summary> /// <summary>
/// Scope granting permission to submit drafts for review. /// Scope granting permission to submit drafts for review.
/// </summary> /// </summary>
public const string PolicySubmit = "policy:submit"; public const string PolicySubmit = "policy:submit";
/// <summary> /// <summary>
/// Scope granting permission to approve or reject policies. /// Scope granting permission to approve or reject policies.
/// </summary> /// </summary>
public const string PolicyApprove = "policy:approve"; public const string PolicyApprove = "policy:approve";
/// <summary> /// <summary>
/// Scope granting permission to operate Policy Studio promotions and runs. /// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary> /// </summary>
public const string PolicyOperate = "policy:operate"; public const string PolicyOperate = "policy:operate";
@@ -172,37 +172,37 @@ public static class StellaOpsScopes
/// Scope granting permission to audit Policy Studio activity. /// Scope granting permission to audit Policy Studio activity.
/// </summary> /// </summary>
public const string PolicyAudit = "policy:audit"; public const string PolicyAudit = "policy:audit";
/// <summary> /// <summary>
/// Scope granting permission to trigger policy runs and activation workflows. /// Scope granting permission to trigger policy runs and activation workflows.
/// </summary> /// </summary>
public const string PolicyRun = "policy:run"; public const string PolicyRun = "policy:run";
/// <summary> /// <summary>
/// Scope granting permission to activate policies. /// Scope granting permission to activate policies.
/// </summary> /// </summary>
public const string PolicyActivate = "policy:activate"; public const string PolicyActivate = "policy:activate";
/// <summary> /// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine. /// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary> /// </summary>
public const string FindingsRead = "findings:read"; public const string FindingsRead = "findings:read";
/// <summary> /// <summary>
/// Scope granting permission to run Policy Studio simulations. /// Scope granting permission to run Policy Studio simulations.
/// </summary> /// </summary>
public const string PolicySimulate = "policy:simulate"; public const string PolicySimulate = "policy:simulate";
/// <summary> /// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings. /// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary> /// </summary>
public const string EffectiveWrite = "effective:write"; public const string EffectiveWrite = "effective:write";
/// <summary> /// <summary>
/// Scope granting read-only access to graph queries and overlays. /// Scope granting read-only access to graph queries and overlays.
/// </summary> /// </summary>
public const string GraphRead = "graph:read"; public const string GraphRead = "graph:read";
/// <summary> /// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks. /// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary> /// </summary>
@@ -269,14 +269,14 @@ public static class StellaOpsScopes
/// </summary> /// </summary>
public const string ObservabilityIncident = "obs:incident"; public const string ObservabilityIncident = "obs:incident";
/// <summary> /// <summary>
/// Scope granting read-only access to export center runs and bundles. /// Scope granting read-only access to export center runs and bundles.
/// </summary> /// </summary>
public const string ExportViewer = "export.viewer"; public const string ExportViewer = "export.viewer";
/// <summary> /// <summary>
/// Scope granting permission to operate export center scheduling and run execution. /// Scope granting permission to operate export center scheduling and run execution.
/// </summary> /// </summary>
public const string ExportOperator = "export.operator"; public const string ExportOperator = "export.operator";
/// <summary> /// <summary>
@@ -339,27 +339,27 @@ public static class StellaOpsScopes
/// </summary> /// </summary>
public const string PacksApprove = "packs.approve"; public const string PacksApprove = "packs.approve";
/// <summary> /// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs. /// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary> /// </summary>
public const string GraphWrite = "graph:write"; public const string GraphWrite = "graph:write";
/// <summary> /// <summary>
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.). /// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
/// </summary> /// </summary>
public const string GraphExport = "graph:export"; public const string GraphExport = "graph:export";
/// <summary> /// <summary>
/// Scope granting permission to trigger what-if simulations on graphs. /// Scope granting permission to trigger what-if simulations on graphs.
/// </summary> /// </summary>
public const string GraphSimulate = "graph:simulate"; public const string GraphSimulate = "graph:simulate";
/// <summary> /// <summary>
/// Scope granting read-only access to Orchestrator job state and telemetry. /// Scope granting read-only access to Orchestrator job state and telemetry.
/// </summary> /// </summary>
public const string OrchRead = "orch:read"; public const string OrchRead = "orch:read";
/// <summary> /// <summary>
/// Scope granting permission to execute Orchestrator control actions. /// Scope granting permission to execute Orchestrator control actions.
/// </summary> /// </summary>
public const string OrchOperate = "orch:operate"; public const string OrchOperate = "orch:operate";
@@ -374,21 +374,21 @@ public static class StellaOpsScopes
/// </summary> /// </summary>
public const string OrchBackfill = "orch:backfill"; public const string OrchBackfill = "orch:backfill";
/// <summary> /// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs. /// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary> /// </summary>
public const string AuthorityTenantsRead = "authority:tenants.read"; public const string AuthorityTenantsRead = "authority:tenants.read";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{ {
ConcelierJobsTrigger, ConcelierJobsTrigger,
ConcelierMerge, ConcelierMerge,
AuthorityUsersManage, AuthorityUsersManage,
AuthorityClientsManage, AuthorityClientsManage,
AuthorityAuditRead, AuthorityAuditRead,
Bypass, Bypass,
UiRead, UiRead,
ExceptionsApprove, ExceptionsApprove,
AdvisoryRead, AdvisoryRead,
AdvisoryIngest, AdvisoryIngest,
AdvisoryAiView, AdvisoryAiView,
@@ -406,8 +406,8 @@ public static class StellaOpsScopes
PolicyWrite, PolicyWrite,
PolicyAuthor, PolicyAuthor,
PolicyEdit, PolicyEdit,
PolicyRead, PolicyRead,
PolicyReview, PolicyReview,
PolicySubmit, PolicySubmit,
PolicyApprove, PolicyApprove,
PolicyOperate, PolicyOperate,
@@ -416,9 +416,9 @@ public static class StellaOpsScopes
PolicyAudit, PolicyAudit,
PolicyRun, PolicyRun,
PolicyActivate, PolicyActivate,
PolicySimulate, PolicySimulate,
FindingsRead, FindingsRead,
EffectiveWrite, EffectiveWrite,
GraphRead, GraphRead,
VulnView, VulnView,
VulnInvestigate, VulnInvestigate,
@@ -458,33 +458,33 @@ public static class StellaOpsScopes
OrchQuota, OrchQuota,
AuthorityTenantsRead AuthorityTenantsRead
}; };
/// <summary> /// <summary>
/// Normalises a scope string (trim/convert to lower case). /// Normalises a scope string (trim/convert to lower case).
/// </summary> /// </summary>
/// <param name="scope">Scope raw value.</param> /// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns> /// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope) public static string? Normalize(string? scope)
{ {
if (string.IsNullOrWhiteSpace(scope)) if (string.IsNullOrWhiteSpace(scope))
{ {
return null; return null;
} }
return scope.Trim().ToLowerInvariant(); return scope.Trim().ToLowerInvariant();
} }
/// <summary> /// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope. /// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary> /// </summary>
public static bool IsKnown(string scope) public static bool IsKnown(string scope)
{ {
ArgumentNullException.ThrowIfNull(scope); ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope); return KnownScopes.Contains(scope);
} }
/// <summary> /// <summary>
/// Returns the full set of built-in scopes. /// Returns the full set of built-in scopes.
/// </summary> /// </summary>
public static IReadOnlyCollection<string> All => KnownScopes; public static IReadOnlyCollection<string> All => KnownScopes;
} }

View File

@@ -1,27 +1,27 @@
namespace StellaOps.Auth.Abstractions; namespace StellaOps.Auth.Abstractions;
/// <summary> /// <summary>
/// Canonical identifiers for StellaOps service principals. /// Canonical identifiers for StellaOps service principals.
/// </summary> /// </summary>
public static class StellaOpsServiceIdentities public static class StellaOpsServiceIdentities
{ {
/// <summary> /// <summary>
/// Service identity used by Policy Engine when materialising effective findings. /// Service identity used by Policy Engine when materialising effective findings.
/// </summary> /// </summary>
public const string PolicyEngine = "policy-engine"; public const string PolicyEngine = "policy-engine";
/// <summary> /// <summary>
/// Service identity used by Cartographer when constructing and maintaining graph projections. /// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary> /// </summary>
public const string Cartographer = "cartographer"; public const string Cartographer = "cartographer";
/// <summary> /// <summary>
/// Service identity used by Vuln Explorer when issuing scoped permalink requests. /// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary> /// </summary>
public const string VulnExplorer = "vuln-explorer"; public const string VulnExplorer = "vuln-explorer";
/// <summary> /// <summary>
/// Service identity used by Signals components when managing reachability facts. /// Service identity used by Signals components when managing reachability facts.
/// </summary> /// </summary>
public const string Signals = "signals"; public const string Signals = "signals";
} }

View File

@@ -1,12 +1,12 @@
namespace StellaOps.Auth.Abstractions; namespace StellaOps.Auth.Abstractions;
/// <summary> /// <summary>
/// Shared tenancy default values used across StellaOps services. /// Shared tenancy default values used across StellaOps services.
/// </summary> /// </summary>
public static class StellaOpsTenancyDefaults public static class StellaOpsTenancyDefaults
{ {
/// <summary> /// <summary>
/// Sentinel value indicating the token is not scoped to a specific project. /// Sentinel value indicating the token is not scoped to a specific project.
/// </summary> /// </summary>
public const string AnyProject = "*"; public const string AnyProject = "*";
} }

View File

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

View File

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

View File

@@ -1,42 +1,42 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client; namespace StellaOps.Auth.Client;
/// <summary> /// <summary>
/// Abstraction for requesting tokens from StellaOps Authority. /// Abstraction for requesting tokens from StellaOps Authority.
/// </summary> /// </summary>
public interface IStellaOpsTokenClient public interface IStellaOpsTokenClient
{ {
/// <summary> /// <summary>
/// Requests an access token using the resource owner password credentials flow. /// Requests an access token using the resource owner password credentials flow.
/// </summary> /// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default); Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Requests an access token using the client credentials flow. /// Requests an access token using the client credentials flow.
/// </summary> /// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default); Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves the cached JWKS document. /// Retrieves the cached JWKS document.
/// </summary> /// </summary>
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default); Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves a cached token entry. /// Retrieves a cached token entry.
/// </summary> /// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default); ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Persists a token entry in the cache. /// Persists a token entry in the cache.
/// </summary> /// </summary>
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default); ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Removes a cached entry. /// Removes a cached entry.
/// </summary> /// </summary>
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default); ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
} }

View File

@@ -1,236 +1,236 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client; namespace StellaOps.Auth.Client;
/// <summary> /// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>. /// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary> /// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{ {
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json"); private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private readonly HttpClient httpClient; private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache; private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly StellaOpsJwksCache jwksCache; private readonly StellaOpsJwksCache jwksCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor; private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly IStellaOpsTokenCache tokenCache; private readonly IStellaOpsTokenCache tokenCache;
private readonly TimeProvider timeProvider; private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsTokenClient>? logger; private readonly ILogger<StellaOpsTokenClient>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public StellaOpsTokenClient( public StellaOpsTokenClient(
HttpClient httpClient, HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache, StellaOpsDiscoveryCache discoveryCache,
StellaOpsJwksCache jwksCache, StellaOpsJwksCache jwksCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
IStellaOpsTokenCache tokenCache, IStellaOpsTokenCache tokenCache,
TimeProvider? timeProvider = null, TimeProvider? timeProvider = null,
ILogger<StellaOpsTokenClient>? logger = null) ILogger<StellaOpsTokenClient>? logger = null)
{ {
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache)); this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache)); this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache)); this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
this.timeProvider = timeProvider ?? TimeProvider.System; this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger; this.logger = logger;
} }
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync( public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username, string username,
string password, string password,
string? scope = null, string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null, IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(username); ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password); ArgumentException.ThrowIfNullOrWhiteSpace(password);
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var parameters = new Dictionary<string, string>(StringComparer.Ordinal) var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{ {
["grant_type"] = "password", ["grant_type"] = "password",
["username"] = username, ["username"] = username,
["password"] = password, ["password"] = password,
["client_id"] = options.ClientId ["client_id"] = options.ClientId
}; };
if (!string.IsNullOrEmpty(options.ClientSecret)) if (!string.IsNullOrEmpty(options.ClientSecret))
{ {
parameters["client_secret"] = options.ClientSecret; parameters["client_secret"] = options.ClientSecret;
} }
AppendScope(parameters, scope, options); AppendScope(parameters, scope, options);
if (additionalParameters is not null) if (additionalParameters is not null)
{ {
foreach (var (key, value) in additionalParameters) foreach (var (key, value) in additionalParameters)
{ {
if (string.IsNullOrWhiteSpace(key) || value is null) if (string.IsNullOrWhiteSpace(key) || value is null)
{ {
continue; continue;
} }
parameters[key] = value; parameters[key] = value;
} }
} }
return RequestTokenAsync(parameters, cancellationToken); return RequestTokenAsync(parameters, cancellationToken);
} }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default) public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{ {
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId)) if (string.IsNullOrWhiteSpace(options.ClientId))
{ {
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured."); throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
} }
var parameters = new Dictionary<string, string>(StringComparer.Ordinal) var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{ {
["grant_type"] = "client_credentials", ["grant_type"] = "client_credentials",
["client_id"] = options.ClientId ["client_id"] = options.ClientId
}; };
if (!string.IsNullOrEmpty(options.ClientSecret)) if (!string.IsNullOrEmpty(options.ClientSecret))
{ {
parameters["client_secret"] = options.ClientSecret; parameters["client_secret"] = options.ClientSecret;
} }
AppendScope(parameters, scope, options); AppendScope(parameters, scope, options);
if (additionalParameters is not null) if (additionalParameters is not null)
{ {
foreach (var (key, value) in additionalParameters) foreach (var (key, value) in additionalParameters)
{ {
if (string.IsNullOrWhiteSpace(key) || value is null) if (string.IsNullOrWhiteSpace(key) || value is null)
{ {
continue; continue;
} }
parameters[key] = value; parameters[key] = value;
} }
} }
return RequestTokenAsync(parameters, cancellationToken); return RequestTokenAsync(parameters, cancellationToken);
} }
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> jwksCache.GetAsync(cancellationToken); => jwksCache.GetAsync(cancellationToken);
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.GetAsync(key, cancellationToken); => tokenCache.GetAsync(key, cancellationToken);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> tokenCache.SetAsync(key, entry, cancellationToken); => tokenCache.SetAsync(key, entry, cancellationToken);
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.RemoveAsync(key, cancellationToken); => tokenCache.RemoveAsync(key, cancellationToken);
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken) private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{ {
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{ {
Content = new FormUrlEncodedContent(parameters) Content = new FormUrlEncodedContent(parameters)
}; };
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString()); request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload); logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}."); throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
} }
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions); var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken)) if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
{ {
throw new InvalidOperationException("Token response did not contain an access_token."); throw new InvalidOperationException("Token response did not contain an access_token.");
} }
var expiresIn = document.ExpiresIn ?? 3600; var expiresIn = document.ExpiresIn ?? 3600;
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn); var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope")); var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
var result = new StellaOpsTokenResult( var result = new StellaOpsTokenResult(
document.AccessToken, document.AccessToken,
document.TokenType ?? "Bearer", document.TokenType ?? "Bearer",
expiresAt, expiresAt,
normalizedScopes, normalizedScopes,
document.RefreshToken, document.RefreshToken,
document.IdToken, document.IdToken,
payload); payload);
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt); logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
return result; return result;
} }
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options) private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{ {
var resolvedScope = scope; var resolvedScope = scope;
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0) if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
{ {
resolvedScope = string.Join(' ', options.NormalizedScopes); resolvedScope = string.Join(' ', options.NormalizedScopes);
} }
if (!string.IsNullOrWhiteSpace(resolvedScope)) if (!string.IsNullOrWhiteSpace(resolvedScope))
{ {
parameters["scope"] = resolvedScope; parameters["scope"] = resolvedScope;
} }
} }
private static string[] ParseScopes(string? scope) private static string[] ParseScopes(string? scope)
{ {
if (string.IsNullOrWhiteSpace(scope)) if (string.IsNullOrWhiteSpace(scope))
{ {
return Array.Empty<string>(); return Array.Empty<string>();
} }
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) if (parts.Length == 0)
{ {
return Array.Empty<string>(); return Array.Empty<string>();
} }
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal); var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
foreach (var part in parts) foreach (var part in parts)
{ {
unique.Add(part); unique.Add(part);
} }
var result = new string[unique.Count]; var result = new string[unique.Count];
unique.CopyTo(result); unique.CopyTo(result);
Array.Sort(result, StringComparer.Ordinal); Array.Sort(result, StringComparer.Ordinal);
return result; return result;
} }
private sealed record TokenResponseDocument( private sealed record TokenResponseDocument(
[property: JsonPropertyName("access_token")] string? AccessToken, [property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken, [property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("id_token")] string? IdToken, [property: JsonPropertyName("id_token")] string? IdToken,
[property: JsonPropertyName("token_type")] string? TokenType, [property: JsonPropertyName("token_type")] string? TokenType,
[property: JsonPropertyName("expires_in")] int? ExpiresIn, [property: JsonPropertyName("expires_in")] int? ExpiresIn,
[property: JsonPropertyName("scope")] string? Scope, [property: JsonPropertyName("scope")] string? Scope,
[property: JsonPropertyName("error")] string? Error, [property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("error_description")] string? ErrorDescription); [property: JsonPropertyName("error_description")] string? ErrorDescription);
} }

View File

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

View File

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

View File

@@ -15,21 +15,21 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Cryptography.Audit; using StellaOps.Cryptography.Audit;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using Xunit; using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests; namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests public class StellaOpsScopeAuthorizationHandlerTests
{ {
[Fact] [Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent() public async Task HandleRequirement_Succeeds_WhenScopePresent()
{ {
var optionsMonitor = CreateOptionsMonitor(options => var optionsMonitor = CreateOptionsMonitor(options =>
{ {
options.Authority = "https://authority.example"; options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha"); options.RequiredTenants.Add("tenant-alpha");
options.Validate(); options.Validate();
}); });
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1")); var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder() var principal = new StellaOpsPrincipalBuilder()
@@ -108,9 +108,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
} }
[Fact] [Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass() public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{ {
var optionsMonitor = CreateOptionsMonitor(options => var optionsMonitor = CreateOptionsMonitor(options =>
{ {
options.Authority = "https://authority.example"; options.Authority = "https://authority.example";
options.Validate(); options.Validate();
@@ -133,9 +133,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
[Fact] [Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing() public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{ {
var optionsMonitor = CreateOptionsMonitor(options => var optionsMonitor = CreateOptionsMonitor(options =>
{ {
options.Authority = "https://authority.example"; options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun); options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate(); options.Validate();
}); });
@@ -162,9 +162,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
[Fact] [Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent() public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{ {
var optionsMonitor = CreateOptionsMonitor(options => var optionsMonitor = CreateOptionsMonitor(options =>
{ {
options.Authority = "https://authority.example"; options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun); options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate(); options.Validate();
}); });
@@ -514,24 +514,24 @@ public class StellaOpsScopeAuthorizationHandlerTests
{ {
private readonly TOptions value; private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure) public TestOptionsMonitor(Action<TOptions> configure)
{ {
value = new TOptions(); value = new TOptions();
configure(value); configure(value);
} }
public TOptions CurrentValue => value; public TOptions CurrentValue => value;
public TOptions Get(string? name) => value; public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance; public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable private sealed class NullDisposable : IDisposable
{ {
public static NullDisposable Instance { get; } = new(); public static NullDisposable Instance { get; } = new();
public void Dispose() public void Dispose()
{ {
} }
} }
} }
} }

View File

@@ -1,92 +1,92 @@
using System; using System;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration; namespace StellaOps.Auth.ServerIntegration;
/// <summary> /// <summary>
/// Dependency injection helpers for configuring StellaOps resource server authentication. /// Dependency injection helpers for configuring StellaOps resource server authentication.
/// </summary> /// </summary>
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
/// <summary> /// <summary>
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section. /// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
/// </summary> /// </summary>
/// <param name="services">The service collection.</param> /// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration.</param> /// <param name="configuration">Application configuration.</param>
/// <param name="configurationSection"> /// <param name="configurationSection">
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding. /// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
/// </param> /// </param>
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param> /// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
public static IServiceCollection AddStellaOpsResourceServerAuthentication( public static IServiceCollection AddStellaOpsResourceServerAuthentication(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration, IConfiguration configuration,
string? configurationSection = "Authority:ResourceServer", string? configurationSection = "Authority:ResourceServer",
Action<StellaOpsResourceServerOptions>? configure = null) Action<StellaOpsResourceServerOptions>? configure = null)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddAuthorization(); services.AddAuthorization();
services.AddStellaOpsScopeHandler(); services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>(); services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System); services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName); services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>(); services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>(); var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection)) if (!string.IsNullOrWhiteSpace(configurationSection))
{ {
optionsBuilder.Bind(configuration.GetSection(configurationSection)); optionsBuilder.Bind(configuration.GetSection(configurationSection));
} }
if (configure is not null) if (configure is not null)
{ {
optionsBuilder.Configure(configure); optionsBuilder.Configure(configure);
} }
optionsBuilder.PostConfigure(static options => options.Validate()); optionsBuilder.PostConfigure(static options => options.Validate());
var authenticationBuilder = services.AddAuthentication(options => var authenticationBuilder = services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
}); });
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme); authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme) services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) => .Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{ {
var resourceOptions = monitor.CurrentValue; var resourceOptions = monitor.CurrentValue;
jwt.Authority = resourceOptions.AuthorityUri.ToString(); jwt.Authority = resourceOptions.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress)) if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
{ {
jwt.MetadataAddress = resourceOptions.MetadataAddress; jwt.MetadataAddress = resourceOptions.MetadataAddress;
} }
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata; jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout; jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
jwt.MapInboundClaims = false; jwt.MapInboundClaims = false;
jwt.SaveToken = false; jwt.SaveToken = false;
jwt.TokenValidationParameters ??= new TokenValidationParameters(); jwt.TokenValidationParameters ??= new TokenValidationParameters();
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString(); jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0; jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences; jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew; jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name; jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>(); jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
}); });
return services; return services;
} }
} }

View File

@@ -1,116 +1,116 @@
using System; using System;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration; namespace StellaOps.Auth.ServerIntegration;
/// <summary> /// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS. /// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary> /// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration> internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{ {
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata"; internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor; private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider; private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger; private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1); private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration; private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt; private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager( public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger) ILogger<StellaOpsAuthorityConfigurationManager> logger)
{ {
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{ {
var now = timeProvider.GetUtcNow(); var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration); var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt) if (current is not null && now < cacheExpiresAt)
{ {
return current; return current;
} }
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
if (cachedConfiguration is not null && now < cacheExpiresAt) if (cachedConfiguration is not null && now < cacheExpiresAt)
{ {
return cachedConfiguration; return cachedConfiguration;
} }
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options); var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName); var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout; httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient) var retriever = new HttpDocumentRetriever(httpClient)
{ {
RequireHttps = options.RequireHttpsMetadata RequireHttps = options.RequireHttpsMetadata
}; };
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress); logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false); var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString(); configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri)) if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{ {
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri); logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false); var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument); var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear(); configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys) foreach (JsonWebKey key in jsonWebKeySet.Keys)
{ {
configuration.SigningKeys.Add(key); configuration.SigningKeys.Add(key);
} }
} }
cachedConfiguration = configuration; cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime; cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration; return configuration;
} }
finally finally
{ {
refreshLock.Release(); refreshLock.Release();
} }
} }
public void RequestRefresh() public void RequestRefresh()
{ {
Volatile.Write(ref cachedConfiguration, null); Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue; cacheExpiresAt = DateTimeOffset.MinValue;
} }
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options) private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{ {
if (!string.IsNullOrWhiteSpace(options.MetadataAddress)) if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{ {
return options.MetadataAddress; return options.MetadataAddress;
} }
var authority = options.AuthorityUri; var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal)) if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{ {
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute); authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
} }
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri; return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
} }
} }

View File

@@ -1,178 +1,178 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration; namespace StellaOps.Auth.ServerIntegration;
/// <summary> /// <summary>
/// Options controlling StellaOps resource server authentication. /// Options controlling StellaOps resource server authentication.
/// </summary> /// </summary>
public sealed class StellaOpsResourceServerOptions public sealed class StellaOpsResourceServerOptions
{ {
private readonly List<string> audiences = new(); private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new(); private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new(); private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new(); private readonly List<string> bypassNetworks = new();
/// <summary> /// <summary>
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery. /// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
/// </summary> /// </summary>
public string Authority { get; set; } = string.Empty; public string Authority { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Optional explicit OpenID Connect metadata address. /// Optional explicit OpenID Connect metadata address.
/// </summary> /// </summary>
public string? MetadataAddress { get; set; } public string? MetadataAddress { get; set; }
/// <summary> /// <summary>
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim). /// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
/// </summary> /// </summary>
public IList<string> Audiences => audiences; public IList<string> Audiences => audiences;
/// <summary> /// <summary>
/// Scopes enforced by default authorisation policies. /// Scopes enforced by default authorisation policies.
/// </summary> /// </summary>
public IList<string> RequiredScopes => requiredScopes; public IList<string> RequiredScopes => requiredScopes;
/// <summary> /// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks). /// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary> /// </summary>
public IList<string> RequiredTenants => requiredTenants; public IList<string> RequiredTenants => requiredTenants;
/// <summary> /// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation). /// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary> /// </summary>
public IList<string> BypassNetworks => bypassNetworks; public IList<string> BypassNetworks => bypassNetworks;
/// <summary> /// <summary>
/// Whether HTTPS metadata is required when communicating with Authority. /// Whether HTTPS metadata is required when communicating with Authority.
/// </summary> /// </summary>
public bool RequireHttpsMetadata { get; set; } = true; public bool RequireHttpsMetadata { get; set; } = true;
/// <summary> /// <summary>
/// Back-channel timeout when fetching metadata/JWKS. /// Back-channel timeout when fetching metadata/JWKS.
/// </summary> /// </summary>
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary> /// <summary>
/// Clock skew tolerated when validating tokens. /// Clock skew tolerated when validating tokens.
/// </summary> /// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60); public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary> /// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh. /// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary> /// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5); public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary> /// <summary>
/// Gets the canonical Authority URI (populated during validation). /// Gets the canonical Authority URI (populated during validation).
/// </summary> /// </summary>
public Uri AuthorityUri { get; private set; } = null!; public Uri AuthorityUri { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the normalised scope list (populated during validation). /// Gets the normalised scope list (populated during validation).
/// </summary> /// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>(); public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary> /// <summary>
/// Gets the normalised tenant list (populated during validation). /// Gets the normalised tenant list (populated during validation).
/// </summary> /// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>(); public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary> /// <summary>
/// Gets the network matcher used for bypass checks (populated during validation). /// Gets the network matcher used for bypass checks (populated during validation).
/// </summary> /// </summary>
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll; public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
/// <summary> /// <summary>
/// Validates provided configuration and normalises collections. /// Validates provided configuration and normalises collections.
/// </summary> /// </summary>
public void Validate() public void Validate()
{ {
if (string.IsNullOrWhiteSpace(Authority)) if (string.IsNullOrWhiteSpace(Authority))
{ {
throw new InvalidOperationException("Resource server authentication requires an Authority URL."); throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
} }
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri)) if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{ {
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI."); throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
} }
if (RequireHttpsMetadata && if (RequireHttpsMetadata &&
!authorityUri.IsLoopback && !authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) !string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{ {
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required."); throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
} }
if (BackchannelTimeout <= TimeSpan.Zero) if (BackchannelTimeout <= TimeSpan.Zero)
{ {
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero."); throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
} }
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5)) if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
{ {
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes."); throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
} }
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24)) if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{ {
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours."); throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
} }
AuthorityUri = authorityUri; AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false); NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true); NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true); NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false); NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0 NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>() ? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); : requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0 NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>() ? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray(); : requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0 BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll ? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks); : new NetworkMaskMatcher(bypassNetworks);
} }
private static void NormalizeList(IList<string> values, bool toLower) private static void NormalizeList(IList<string> values, bool toLower)
{ {
if (values.Count == 0) if (values.Count == 0)
{ {
return; return;
} }
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--) for (var index = values.Count - 1; index >= 0; index--)
{ {
var value = values[index]; var value = values[index];
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
values.RemoveAt(index); values.RemoveAt(index);
continue; continue;
} }
var trimmed = value.Trim(); var trimmed = value.Trim();
if (toLower) if (toLower)
{ {
trimmed = trimmed.ToLowerInvariant(); trimmed = trimmed.ToLowerInvariant();
} }
if (!seen.Add(trimmed)) if (!seen.Add(trimmed))
{ {
values.RemoveAt(index); values.RemoveAt(index);
continue; continue;
} }
values[index] = trimmed; values[index] = trimmed;
} }
} }
} }

View File

@@ -9,9 +9,9 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes; using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers; using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions; using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
using Xunit; using Xunit;

View File

@@ -10,9 +10,9 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers; using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes; using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions; using StellaOps.Authority.Storage.InMemory.Sessions;
using Xunit; using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials; namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions; using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers; namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;

View File

@@ -5,12 +5,12 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver; using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugin.Ldap.Connections; using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security; using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions; using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning; namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;

View File

@@ -11,8 +11,8 @@ using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections; using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring; using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security; using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit; using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Ldap.Credentials; namespace StellaOps.Authority.Plugin.Ldap.Credentials;

View File

@@ -9,7 +9,7 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials; using StellaOps.Authority.Plugin.Ldap.Credentials;
using StellaOps.Authority.Plugin.Ldap.Monitoring; using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security; using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap; namespace StellaOps.Authority.Plugin.Ldap;

View File

@@ -18,7 +18,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" /> <ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" /> <ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" /> <ProjectReference Include="..\\StellaOps.Authority.Storage.InMemory\\StellaOps.Authority.Storage.InMemory.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" /> <ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" /> <ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,183 +1,183 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using Xunit; using Xunit;
namespace StellaOps.Authority.Plugin.Standard.Tests; namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClientProvisioningStoreTests public class StandardClientProvisioningStoreTests
{ {
[Fact] [Fact]
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument() public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
{ {
var store = new TrackingClientStore(); var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore(); var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration( var registration = new AuthorityClientRegistration(
clientId: "bootstrap-client", clientId: "bootstrap-client",
confidential: true, confidential: true,
displayName: "Bootstrap", displayName: "Bootstrap",
clientSecret: "SuperSecret1!", clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" }, allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" }); allowedScopes: new[] { "scopeA" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document)); Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
Assert.NotNull(document); Assert.NotNull(document);
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash); Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
Assert.Equal("standard", document.Plugin); Assert.Equal("standard", document.Plugin);
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None); var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
Assert.NotNull(descriptor); Assert.NotNull(descriptor);
Assert.Equal("bootstrap-client", descriptor!.ClientId); Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential); Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes); Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopea", descriptor.AllowedScopes); Assert.Contains("scopea", descriptor.AllowedScopes);
} }
[Fact] [Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant() public async Task CreateOrUpdateAsync_NormalisesTenant()
{ {
var store = new TrackingClientStore(); var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore(); var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration( var registration = new AuthorityClientRegistration(
clientId: "tenant-client", clientId: "tenant-client",
confidential: false, confidential: false,
displayName: "Tenant Client", displayName: "Tenant Client",
clientSecret: null, clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" }, allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" }, allowedScopes: new[] { "scopeA" },
tenant: " Tenant-Alpha " ); tenant: " Tenant-Alpha " );
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("tenant-client", out var document)); Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
Assert.NotNull(document); Assert.NotNull(document);
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]); Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None); var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
Assert.NotNull(descriptor); Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant); Assert.Equal("tenant-alpha", descriptor!.Tenant);
} }
[Fact] [Fact]
public async Task CreateOrUpdateAsync_StoresAudiences() public async Task CreateOrUpdateAsync_StoresAudiences()
{ {
var store = new TrackingClientStore(); var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore(); var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration( var registration = new AuthorityClientRegistration(
clientId: "signer", clientId: "signer",
confidential: false, confidential: false,
displayName: "Signer", displayName: "Signer",
clientSecret: null, clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" }, allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" }, allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" }); allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("signer", out var document)); Assert.True(store.Documents.TryGetValue("signer", out var document));
Assert.NotNull(document); Assert.NotNull(document);
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]); Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None); var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor); Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal)); Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
} }
[Fact] [Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings() public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{ {
var store = new TrackingClientStore(); var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore(); var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration( var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd", thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff", serialNumber: "01ff",
subject: "CN=mtls-client", subject: "CN=mtls-client",
issuer: "CN=test-ca", issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" }, subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5), notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1), notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary"); label: "primary");
var registration = new AuthorityClientRegistration( var registration = new AuthorityClientRegistration(
clientId: "mtls-client", clientId: "mtls-client",
confidential: true, confidential: true,
displayName: "MTLS Client", displayName: "MTLS Client",
clientSecret: "secret", clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" }, allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" }, allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" }, allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration }); certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("mtls-client", out var document)); Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
Assert.NotNull(document); Assert.NotNull(document);
var binding = Assert.Single(document!.CertificateBindings); var binding = Assert.Single(document!.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint); Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber); Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject); Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer); Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames); Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore); Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter); Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label); Assert.Equal("primary", binding.Label);
} }
private sealed class TrackingClientStore : IAuthorityClientStore private sealed class TrackingClientStore : IAuthorityClientStore
{ {
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{ {
Documents.TryGetValue(clientId, out var document); Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document); return ValueTask.FromResult(document);
} }
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{ {
Documents[document.ClientId] = document; Documents[document.ClientId] = document;
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{ {
var removed = Documents.Remove(clientId); var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed); return ValueTask.FromResult(removed);
} }
} }
private sealed class TrackingRevocationStore : IAuthorityRevocationStore private sealed class TrackingRevocationStore : IAuthorityRevocationStore
{ {
public List<AuthorityRevocationDocument> Upserts { get; } = new(); public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{ {
Upserts.Add(document); Upserts.Add(document);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true); => ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>()); => ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
} }
} }

View File

@@ -8,13 +8,13 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver; using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard; using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap; using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit; using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests; namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -24,7 +24,7 @@ public class StandardPluginRegistrarTests
[Fact] [Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser() public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
var database = client.GetDatabase("registrar-tests"); var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -86,7 +86,7 @@ public class StandardPluginRegistrarTests
[Fact] [Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker() public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
var database = client.GetDatabase("registrar-password-policy"); var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -131,7 +131,7 @@ public class StandardPluginRegistrarTests
[Fact] [Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing() public void Register_ForcesPasswordCapability_WhenManifestMissing()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
var database = client.GetDatabase("registrar-capabilities"); var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build(); var configuration = new ConfigurationBuilder().Build();
@@ -163,7 +163,7 @@ public class StandardPluginRegistrarTests
[Fact] [Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete() public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
var database = client.GetDatabase("registrar-bootstrap-validation"); var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -197,7 +197,7 @@ public class StandardPluginRegistrarTests
[Fact] [Fact]
public void Register_NormalizesTokenSigningKeyDirectory() public void Register_NormalizesTokenSigningKeyDirectory()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
var database = client.GetDatabase("registrar-token-signing"); var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -389,7 +389,7 @@ internal sealed class TestAuthEventSink : IAuthEventSink
internal static class StandardPluginRegistrarTestHelpers internal static class StandardPluginRegistrarTestHelpers
{ {
public static ServiceCollection CreateServiceCollection( public static ServiceCollection CreateServiceCollection(
IMongoDatabase database, IDatabase database,
IAuthEventSink? authEventSink = null, IAuthEventSink? authEventSink = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null) IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{ {

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver; using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security; using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Plugin.Standard.Storage;
@@ -16,14 +16,14 @@ namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime public class StandardUserCredentialStoreTests : IAsyncLifetime
{ {
private readonly IMongoDatabase database; private readonly IDatabase database;
private readonly StandardPluginOptions options; private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store; private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger; private readonly TestAuditLogger auditLogger;
public StandardUserCredentialStoreTests() public StandardUserCredentialStoreTests()
{ {
var client = new InMemoryMongoClient(); var client = new InMemoryClient();
database = client.GetDatabase("authority-tests"); database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions options = new StandardPluginOptions
{ {
@@ -171,9 +171,9 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.True(auditEntry.Success); Assert.True(auditEntry.Success);
Assert.Equal("legacy", auditEntry.Username); Assert.Equal("legacy", auditEntry.Username);
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard") var results = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.Find(u => u.NormalizedUsername == "legacy") .FindAsync(u => u.NormalizedUsername == "legacy");
.FirstOrDefaultAsync(); var updated = results.FirstOrDefault();
Assert.NotNull(updated); Assert.NotNull(updated);
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal); Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);

View File

@@ -1,44 +1,44 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Bootstrap; namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
internal sealed class StandardPluginBootstrapper : IHostedService internal sealed class StandardPluginBootstrapper : IHostedService
{ {
private readonly string pluginName; private readonly string pluginName;
private readonly IServiceScopeFactory scopeFactory; private readonly IServiceScopeFactory scopeFactory;
private readonly ILogger<StandardPluginBootstrapper> logger; private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper( public StandardPluginBootstrapper(
string pluginName, string pluginName,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<StandardPluginBootstrapper> logger) ILogger<StandardPluginBootstrapper> logger)
{ {
this.pluginName = pluginName; this.pluginName = pluginName;
this.scopeFactory = scopeFactory; this.scopeFactory = scopeFactory;
this.logger = logger; this.logger = logger;
} }
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(); var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>(); var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName); var options = optionsMonitor.Get(pluginName);
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured) if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{ {
return; return;
} }
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName); logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false); await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }

View File

@@ -1,122 +1,122 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Bootstrap; using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security; using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Repositories; using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Cryptography; using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection; using StellaOps.Cryptography.DependencyInjection;
namespace StellaOps.Authority.Plugin.Standard; namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{ {
private const string DefaultTenantId = "default"; private const string DefaultTenantId = "default";
public string PluginType => "standard"; public string PluginType => "standard";
public void Register(AuthorityPluginRegistrationContext context) public void Register(AuthorityPluginRegistrationContext context)
{ {
if (context is null) if (context is null)
{ {
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
var pluginName = context.Plugin.Manifest.Name; var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>(); context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>()); context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto(); context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath; var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName) context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration) .Bind(context.Plugin.Configuration)
.PostConfigure(options => .PostConfigure(options =>
{ {
options.Normalize(configPath); options.Normalize(configPath);
options.Validate(pluginName); options.Validate(pluginName);
}) })
.ValidateOnStart(); .ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>(); context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp => context.Services.AddScoped(sp =>
{ {
var userRepository = sp.GetRequiredService<IUserRepository>(); var userRepository = sp.GetRequiredService<IUserRepository>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(); var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName); var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>(); var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider); var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>(); var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>(); var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>(); var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions(); var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy)) if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{ {
registrarLogger.LogWarning( registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).", "Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
pluginName, pluginName,
pluginOptions.PasswordPolicy.MinimumLength, pluginOptions.PasswordPolicy.MinimumLength,
pluginOptions.PasswordPolicy.RequireUppercase, pluginOptions.PasswordPolicy.RequireUppercase,
pluginOptions.PasswordPolicy.RequireLowercase, pluginOptions.PasswordPolicy.RequireLowercase,
pluginOptions.PasswordPolicy.RequireDigit, pluginOptions.PasswordPolicy.RequireDigit,
pluginOptions.PasswordPolicy.RequireSymbol, pluginOptions.PasswordPolicy.RequireSymbol,
baselinePolicy.MinimumLength, baselinePolicy.MinimumLength,
baselinePolicy.RequireUppercase, baselinePolicy.RequireUppercase,
baselinePolicy.RequireLowercase, baselinePolicy.RequireLowercase,
baselinePolicy.RequireDigit, baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol); baselinePolicy.RequireSymbol);
} }
// Use tenant from options or default // Use tenant from options or default
var tenantId = pluginOptions.TenantId ?? DefaultTenantId; var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
return new StandardUserCredentialStore( return new StandardUserCredentialStore(
pluginName, pluginName,
tenantId, tenantId,
userRepository, userRepository,
pluginOptions, pluginOptions,
passwordHasher, passwordHasher,
auditLogger, auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>()); loggerFactory.CreateLogger<StandardUserCredentialStore>());
}); });
context.Services.AddScoped(sp => context.Services.AddScoped(sp =>
{ {
var clientStore = sp.GetRequiredService<IAuthorityClientStore>(); var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>(); var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
var timeProvider = sp.GetRequiredService<TimeProvider>(); var timeProvider = sp.GetRequiredService<TimeProvider>();
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider); return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
}); });
context.Services.AddScoped<IIdentityProviderPlugin>(sp => context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
{ {
var store = sp.GetRequiredService<StandardUserCredentialStore>(); var store = sp.GetRequiredService<StandardUserCredentialStore>();
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>(); var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>(); var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardIdentityProviderPlugin( return new StandardIdentityProviderPlugin(
context.Plugin, context.Plugin,
store, store,
clientProvisioningStore, clientProvisioningStore,
sp.GetRequiredService<StandardClaimsEnricher>(), sp.GetRequiredService<StandardClaimsEnricher>(),
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>()); loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
}); });
context.Services.AddScoped<IClientProvisioningStore>(sp => context.Services.AddScoped<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>()); sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp => context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper( new StandardPluginBootstrapper(
pluginName, pluginName,
sp.GetRequiredService<IServiceScopeFactory>(), sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>())); sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
} }
} }

View File

@@ -16,7 +16,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" /> <ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" /> <ProjectReference Include="..\StellaOps.Authority.Storage.InMemory\StellaOps.Authority.Storage.InMemory.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" /> <ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" /> <ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" /> <ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

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

View File

@@ -1,32 +1,32 @@
using System; using System;
using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests; namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityClientRegistrationTests public class AuthorityClientRegistrationTests
{ {
[Fact] [Fact]
public void Constructor_Throws_WhenClientIdMissing() public void Constructor_Throws_WhenClientIdMissing()
{ {
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null)); Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
} }
[Fact] [Fact]
public void Constructor_RequiresSecret_ForConfidentialClients() public void Constructor_RequiresSecret_ForConfidentialClients()
{ {
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null)); Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
} }
[Fact] [Fact]
public void WithClientSecret_ReturnsCopy() public void WithClientSecret_ReturnsCopy()
{ {
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha"); var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
var updated = registration.WithClientSecret("secret"); var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId); Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret); Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential); Assert.False(updated.Confidential);
Assert.Equal("tenant-alpha", updated.Tenant); Assert.Equal("tenant-alpha", updated.Tenant);
} }
} }

View File

@@ -1,117 +1,117 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions; namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary> /// <summary>
/// Well-known Authority plugin capability identifiers. /// Well-known Authority plugin capability identifiers.
/// </summary> /// </summary>
public static class AuthorityPluginCapabilities public static class AuthorityPluginCapabilities
{ {
public const string Password = "password"; public const string Password = "password";
public const string Bootstrap = "bootstrap"; public const string Bootstrap = "bootstrap";
public const string Mfa = "mfa"; public const string Mfa = "mfa";
public const string ClientProvisioning = "clientProvisioning"; public const string ClientProvisioning = "clientProvisioning";
} }
/// <summary> /// <summary>
/// Immutable description of an Authority plugin loaded from configuration. /// Immutable description of an Authority plugin loaded from configuration.
/// </summary> /// </summary>
/// <param name="Name">Logical name derived from configuration key.</param> /// <param name="Name">Logical name derived from configuration key.</param>
/// <param name="Type">Plugin type identifier (used for capability routing).</param> /// <param name="Type">Plugin type identifier (used for capability routing).</param>
/// <param name="Enabled">Whether the plugin is enabled.</param> /// <param name="Enabled">Whether the plugin is enabled.</param>
/// <param name="AssemblyName">Assembly name without extension.</param> /// <param name="AssemblyName">Assembly name without extension.</param>
/// <param name="AssemblyPath">Explicit assembly path override.</param> /// <param name="AssemblyPath">Explicit assembly path override.</param>
/// <param name="Capabilities">Capability hints exposed by the plugin.</param> /// <param name="Capabilities">Capability hints exposed by the plugin.</param>
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param> /// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param> /// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
public sealed record AuthorityPluginManifest( public sealed record AuthorityPluginManifest(
string Name, string Name,
string Type, string Type,
bool Enabled, bool Enabled,
string? AssemblyName, string? AssemblyName,
string? AssemblyPath, string? AssemblyPath,
IReadOnlyList<string> Capabilities, IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string?> Metadata, IReadOnlyDictionary<string, string?> Metadata,
string ConfigPath) string ConfigPath)
{ {
/// <summary> /// <summary>
/// Determines whether the manifest declares the specified capability. /// Determines whether the manifest declares the specified capability.
/// </summary> /// </summary>
/// <param name="capability">Capability identifier to check.</param> /// <param name="capability">Capability identifier to check.</param>
public bool HasCapability(string capability) public bool HasCapability(string capability)
{ {
if (string.IsNullOrWhiteSpace(capability)) if (string.IsNullOrWhiteSpace(capability))
{ {
return false; return false;
} }
foreach (var entry in Capabilities) foreach (var entry in Capabilities)
{ {
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase)) if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
{ {
return true; return true;
} }
} }
return false; return false;
} }
} }
/// <summary> /// <summary>
/// Runtime context combining plugin manifest metadata and its bound configuration. /// Runtime context combining plugin manifest metadata and its bound configuration.
/// </summary> /// </summary>
/// <param name="Manifest">Manifest describing the plugin.</param> /// <param name="Manifest">Manifest describing the plugin.</param>
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param> /// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
public sealed record AuthorityPluginContext( public sealed record AuthorityPluginContext(
AuthorityPluginManifest Manifest, AuthorityPluginManifest Manifest,
IConfiguration Configuration); IConfiguration Configuration);
/// <summary> /// <summary>
/// Registry exposing the set of Authority plugins loaded at runtime. /// Registry exposing the set of Authority plugins loaded at runtime.
/// </summary> /// </summary>
public interface IAuthorityPluginRegistry public interface IAuthorityPluginRegistry
{ {
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; } IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context); bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
AuthorityPluginContext GetRequired(string name) AuthorityPluginContext GetRequired(string name)
{ {
if (TryGet(name, out var context)) if (TryGet(name, out var context))
{ {
return context; return context;
} }
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered."); throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
} }
} }
/// <summary> /// <summary>
/// Registry exposing loaded identity provider plugins and their capabilities. /// Registry exposing loaded identity provider plugins and their capabilities.
/// </summary> /// </summary>
public interface IAuthorityIdentityProviderRegistry public interface IAuthorityIdentityProviderRegistry
{ {
/// <summary> /// <summary>
/// Gets metadata for all registered identity provider plugins. /// Gets metadata for all registered identity provider plugins.
/// </summary> /// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
/// <summary> /// <summary>
/// Gets metadata for identity providers that advertise password support. /// Gets metadata for identity providers that advertise password support.
/// </summary> /// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
/// <summary> /// <summary>
/// Gets metadata for identity providers that advertise multi-factor authentication support. /// Gets metadata for identity providers that advertise multi-factor authentication support.
/// </summary> /// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; } IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary> /// <summary>
/// Gets metadata for identity providers that advertise client provisioning support. /// Gets metadata for identity providers that advertise client provisioning support.
/// </summary> /// </summary>
@@ -126,91 +126,91 @@ public interface IAuthorityIdentityProviderRegistry
/// Aggregate capability flags across all registered providers. /// Aggregate capability flags across all registered providers.
/// </summary> /// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; } AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary> /// <summary>
/// Attempts to resolve identity provider metadata by name. /// Attempts to resolve identity provider metadata by name.
/// </summary> /// </summary>
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata); bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
/// <summary> /// <summary>
/// Resolves identity provider metadata by name or throws when not found. /// Resolves identity provider metadata by name or throws when not found.
/// </summary> /// </summary>
AuthorityIdentityProviderMetadata GetRequired(string name) AuthorityIdentityProviderMetadata GetRequired(string name)
{ {
if (TryGet(name, out var metadata)) if (TryGet(name, out var metadata))
{ {
return metadata; return metadata;
} }
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered."); throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
} }
/// <summary> /// <summary>
/// Acquires a scoped handle to the specified identity provider. /// Acquires a scoped handle to the specified identity provider.
/// </summary> /// </summary>
/// <param name="name">Logical provider name.</param> /// <param name="name">Logical provider name.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Handle managing the provider instance lifetime.</returns> /// <returns>Handle managing the provider instance lifetime.</returns>
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken); ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
/// Immutable metadata describing a registered identity provider. /// Immutable metadata describing a registered identity provider.
/// </summary> /// </summary>
/// <param name="Name">Logical provider name from the manifest.</param> /// <param name="Name">Logical provider name from the manifest.</param>
/// <param name="Type">Provider type identifier.</param> /// <param name="Type">Provider type identifier.</param>
/// <param name="Capabilities">Capability flags advertised by the provider.</param> /// <param name="Capabilities">Capability flags advertised by the provider.</param>
public sealed record AuthorityIdentityProviderMetadata( public sealed record AuthorityIdentityProviderMetadata(
string Name, string Name,
string Type, string Type,
AuthorityIdentityProviderCapabilities Capabilities); AuthorityIdentityProviderCapabilities Capabilities);
/// <summary> /// <summary>
/// Represents a scoped identity provider instance and manages its disposal. /// Represents a scoped identity provider instance and manages its disposal.
/// </summary> /// </summary>
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
{ {
private readonly AsyncServiceScope scope; private readonly AsyncServiceScope scope;
private bool disposed; private bool disposed;
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider) public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
{ {
this.scope = scope; this.scope = scope;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Provider = provider ?? throw new ArgumentNullException(nameof(provider)); Provider = provider ?? throw new ArgumentNullException(nameof(provider));
} }
/// <summary> /// <summary>
/// Gets the metadata associated with the provider instance. /// Gets the metadata associated with the provider instance.
/// </summary> /// </summary>
public AuthorityIdentityProviderMetadata Metadata { get; } public AuthorityIdentityProviderMetadata Metadata { get; }
/// <summary> /// <summary>
/// Gets the active provider instance. /// Gets the active provider instance.
/// </summary> /// </summary>
public IIdentityProviderPlugin Provider { get; } public IIdentityProviderPlugin Provider { get; }
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
if (disposed) if (disposed)
{ {
return; return;
} }
disposed = true; disposed = true;
scope.Dispose(); scope.Dispose();
} }
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (disposed) if (disposed)
{ {
return; return;
} }
disposed = true; disposed = true;
await scope.DisposeAsync().ConfigureAwait(false); await scope.DisposeAsync().ConfigureAwait(false);
} }
} }

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