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).
* **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**.
* **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.
* **Attest or it didnt happen.** Every export is signed as **in-toto/DSSE** and logged in **Rekor v2**.
* **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.
* **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.
* **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`.
@@ -46,7 +47,7 @@
| **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. |
| **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. |
### 1.2 Thirdparty (selfhosted)

View File

@@ -6,7 +6,7 @@
- **Working directory:** `src/Web/StellaOps.Web`
## 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.
- Parallel tracks: Can run alongside UI II/III for shared component work.
- Blockers to flag: VEX decision API schema finalization, Attestation viewer predicates.
@@ -18,59 +18,58 @@
- `docs/modules/ui/architecture.md`
- `docs/modules/vuln-explorer/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/27-Nov-2025 - Explainability Layer for Vulnerability Verdicts.md`
- `docs/product-advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md` (canonical)
- `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/audit-bundle-index.schema.json`
## Delivery Tracker
| # | 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. |
| 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). |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 30 | SCHEMA-08-001 | TODO | - | Platform Guild | Create docs/schemas/vex-decision.schema.json with JSON Schema 2020-12 definition per advisory. |
| 31 | SCHEMA-08-002 | TODO | SCHEMA-08-001 | Platform Guild | Create docs/schemas/attestation-vuln-scan.schema.json for vulnerability scan attestation predicate. |
| 32 | SCHEMA-08-003 | TODO | SCHEMA-08-002 | Platform Guild | Create docs/schemas/audit-bundle-index.schema.json for audit bundle manifest structure. |
| 33 | DTO-09-001 | TODO | SCHEMA-08-001 | 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. |
| 35 | DTO-09-003 | TODO | SCHEMA-08-003 | 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | BLOCKED | Blocked: depends on SCHEMA-08-001 finalization | API Guild | Create VexDecisionDto, SubjectRefDto, EvidenceRefDto, VexScopeDto, ValidForDto 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 | BLOCKED | Blocked: depends on SCHEMA-08-003 finalization | API Guild | Create AuditBundleIndexDto, BundleArtifactDto, BundleVexDecisionEntryDto C# DTOs 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 A (Schemas & DTOs):** SCHEMA-08-*, DTO-09-*, TS-10-* - Foundation work
@@ -80,7 +79,7 @@
## Wave Detail Snapshots
### Wave A - Schemas & Types
- 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
### Wave B - Backend APIs
@@ -112,7 +111,8 @@
| 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 |
| 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
| Risk | Impact | Mitigation / Next Step |
@@ -121,20 +121,22 @@
| 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 |
| 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 |
| 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 |
| Advisory doc sync lag | Docs drift from UX/API decisions | DOC-11-* DONE; re-review docs when schemas/APIs finalize |
| 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 |
## Execution Log
| 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 | 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 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-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*

View File

@@ -1,10 +1,10 @@
# Sprint 0401 - Reachability Evidence Chain
# Sprint 0401.0001.0001 - Reachability Evidence Chain
## Topic & Scope
- 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.
- 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
- 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 | 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. |
| 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. |
| 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. |
| 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. |
| 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 | TODO | Assess runtime ingestion/probe readiness and flip task statuses to DOING/BLOCKED accordingly. |
## Decisions & Risks
- 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 |
| --- | --- | --- |
| 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 | 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 |

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
| # | 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 |
| 41 | MR-T10.11.2 | BLOCKED | MR-T10.11.1 | Infrastructure Guild | Remove MongoDB.Bson 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 | 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 |
| 43 | MR-T10.11.4 | BLOCKED | MR-T10.11.3 | Infrastructure Guild | Remove `StellaOps.Provenance.Mongo` project |
| 44 | MR-T10.11.5 | BLOCKED | MR-T10.11.4 | Infrastructure Guild | Final grep verification: zero MongoDB references |
| 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 | 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
- 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 | 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-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.
<!-- TODO: Review for separate approval - added Decision Capsules as feature 0 -->
## 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.
## 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.
- **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
- **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_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_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. |
## 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.
| `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. |
## Audit bundles (immutable triage exports)
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.
- **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.
- **Trivy (`trivy:db`, `trivy:java-db`).**
- 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.
## 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.
- 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.
- 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.
- 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
- 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/secret-leak-detection.md
- ./operations/dsse-rekor-operator-guide.md
- ./os-analyzers-evidence.md
- ./design/macos-analyzer.md
- ./design/windows-analyzer.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
├─ runtime/ # Zastava posture, drift events, admission decisions
├─ policy/ # rules editor (YAML/Rego), exemptions, previews
├─ vex/ # VEX explorer (claims, consensus, conflicts)
├─ concelier/ # source health, export cursors, rebuild/export triggers
├─ vex/ # VEX explorer (claims, consensus, conflicts)
├─ triage/ # vulnerability triage (artifact-first), VEX decisions, audit bundles
├─ concelier/ # source health, export cursors, rebuild/export triggers
├─ attest/ # attestation proofs, verification bundles, Rekor links
├─ admin/ # tenants, roles, clients, quotas, licensing posture
└─ 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).
* **Verification**: paste UUID or upload bundle → verify; result with explanations (chain, Merkle path).
### 3.8 Admin
* **Tenants/Installations**: view/edit, isolation hints.
* **Clients & roles**: Authority clients, role→scope mapping, rotation hints.
* **Quotas**: per license plan, counters, throttle events.
* **Licensing posture**: last PoE introspection snapshot (redacted), release window.
---
### 3.8 Admin
* **Tenants/Installations**: view/edit, isolation hints.
* **Clients & roles**: Authority clients, role→scope mapping, rotation hints.
* **Quotas**: per license plan, counters, throttle events.
* **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

View File

@@ -79,7 +79,7 @@ CLI mirrors these endpoints (`stella findings list|view|update|export`). Console
## 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
@@ -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` - 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
The triage UX aligns with industry patterns from:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Aoc;
public interface IAocGuard
{
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
}
public sealed class AocWriteGuard : IAocGuard
{
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Aoc;
public interface IAocGuard
{
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
}
public sealed class AocWriteGuard : IAocGuard
{
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
{
options ??= AocGuardOptions.Default;
@@ -22,13 +22,13 @@ public sealed class AocWriteGuard : IAocGuard
{
presentTopLevel.Add(property.Name);
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
{
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
continue;
}
if (AocForbiddenKeys.IsDerivedField(property.Name))
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
{
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
continue;
}
if (AocForbiddenKeys.IsDerivedField(property.Name))
{
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)
{
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."));
continue;
}
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
{
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
{
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 (!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."));
}
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
{
if (options.RequireSignatureMetadata)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
}
}
else if (options.RequireSignatureMetadata)
{
ValidateSignature(signature, violations);
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
}
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)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
}
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
}
return AocGuardResult.FromViolations(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))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
return;
}
var signaturePresent = presentElement.GetBoolean();
if (!signaturePresent)
{
return;
}
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."));
}
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."));
}
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.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
continue;
}
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
{
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
{
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 (!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."));
}
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
{
if (options.RequireSignatureMetadata)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
}
}
else if (options.RequireSignatureMetadata)
{
ValidateSignature(signature, violations);
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
}
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)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
}
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
}
return AocGuardResult.FromViolations(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))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
return;
}
var signaturePresent = presentElement.GetBoolean();
if (!signaturePresent)
{
return;
}
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."));
}
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."));
}
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."));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,12 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;
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.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
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.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap;

View File

@@ -18,7 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.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.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.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.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

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

View File

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

View File

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

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